diff --git a/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt index 9f9a145..72d98e0 100644 --- a/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt +++ b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt @@ -93,12 +93,17 @@ class AstraDesktopDiscoveryModule : Module() { val endpointUuid = txt(resolved, "endpoint_uuid") val desktopName = txt(resolved, "name") val protocolVersion = txt(resolved, "protocol_version")?.toIntOrNull() ?: 1 + val transport = txt(resolved, "transport") + val certificateFingerprint = txt(resolved, "certificate_fingerprint") + if (protocolVersion != 3 || transport != "https" || certificateFingerprint.isNullOrBlank()) return val payload = mapOf( "endpointUuid" to endpointUuid, "desktopName" to desktopName, "protocolVersion" to protocolVersion, + "certificateFingerprint" to certificateFingerprint, + "transport" to "https", "name" to (desktopName ?: resolved.serviceName), - "baseUrl" to "http://$address:$port", + "baseUrl" to "https://$address:$port", "address" to address, "port" to port, "lastSeenAt" to System.currentTimeMillis(), diff --git a/modules/astra-desktop-discovery/index.ts b/modules/astra-desktop-discovery/index.ts index d360739..ba8d8bc 100644 --- a/modules/astra-desktop-discovery/index.ts +++ b/modules/astra-desktop-discovery/index.ts @@ -4,6 +4,8 @@ export interface AstraDesktopDiscoveryItem { endpointUuid: string | null; desktopName: string | null; protocolVersion: number; + certificateFingerprint: string; + transport: 'https'; name: string; baseUrl: string; address: string; diff --git a/modules/astra-desktop-transport/android/build.gradle b/modules/astra-desktop-transport/android/build.gradle new file mode 100644 index 0000000..e23bcd7 --- /dev/null +++ b/modules/astra-desktop-transport/android/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.astradesktoptransport' +version = '0.1.0' + +android { + namespace "expo.modules.astradesktoptransport" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + testImplementation 'junit:junit:4.13.2' +} diff --git a/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml b/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/modules/astra-desktop-transport/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt b/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt new file mode 100644 index 0000000..e16175f --- /dev/null +++ b/modules/astra-desktop-transport/android/src/main/java/expo/modules/astradesktoptransport/AstraDesktopTransportModule.kt @@ -0,0 +1,464 @@ +package expo.modules.astradesktoptransport + +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.net.HttpURLConnection +import java.net.URI +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.security.spec.X509EncodedKeySpec +import java.util.UUID +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSession +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +private const val MAX_REQUEST_BYTES = 2 * 1024 * 1024 +private const val MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +private const val PAIRING_INFO = "astra-phone-remote-v3-pairing" +private const val PAIRING_AAD_PREFIX = "astra-phone-remote-v3-confirm:" + +private data class PairingAttempt( + val baseUrl: String, + val requestId: String, + val expiresAt: Long, + val certificateFingerprint: String, + val key: ByteArray, + val transcript: ByteArray, + val code: String, +) + +private class ExactFingerprintTrustManager(expected: String) : X509TrustManager { + private val expectedFingerprint = normalizeFingerprint(expected) + + init { + require(expectedFingerprint.length == 64) { "Invalid desktop certificate fingerprint." } + } + + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() ?: throw java.security.cert.CertificateException("Desktop sent no certificate.") + if (certificateFingerprint(leaf) != expectedFingerprint) { + throw java.security.cert.CertificateException("Desktop certificate changed.") + } + } + + override fun getAcceptedIssuers(): Array = emptyArray() +} + +internal class ObservingTrustManager : X509TrustManager { + @Volatile var observedFingerprint: String? = null + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() ?: throw java.security.cert.CertificateException("Desktop sent no certificate.") + observedFingerprint = certificateFingerprint(leaf) + } + override fun getAcceptedIssuers(): Array = emptyArray() +} + +private fun normalizeFingerprint(value: String): String = + value.uppercase().filter { it in '0'..'9' || it in 'A'..'F' } + +private fun certificateFingerprint(certificate: X509Certificate): String = + MessageDigest.getInstance("SHA-256").digest(certificate.encoded).joinToString("") { "%02X".format(it) } + +private fun displayFingerprint(value: String): String = + normalizeFingerprint(value).chunked(2).joinToString(":") + +private fun base64Url(bytes: ByteArray): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + +private fun decodeBase64Url(value: String): ByteArray = + Base64.getUrlDecoder().decode(value) + +private fun hmac(key: ByteArray, data: ByteArray): ByteArray = + Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + +private fun hkdf(sharedSecret: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val prk = hmac(salt, sharedSecret) + val output = ByteArrayOutputStream() + var previous = ByteArray(0) + var counter = 1 + while (output.size() < length) { + previous = hmac(prk, previous + info + byteArrayOf(counter.toByte())) + output.write(previous) + counter += 1 + } + return output.toByteArray().copyOf(length) +} + +internal fun derivePairingKey( + privateKey: PrivateKey, + peerPublicKey: String, + transcript: ByteArray, +): ByteArray { + val peerKey = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(decodeBase64Url(peerPublicKey))) + val sharedSecret = KeyAgreement.getInstance("ECDH").run { + init(privateKey) + doPhase(peerKey, true) + generateSecret() + } + return hkdf( + sharedSecret, + MessageDigest.getInstance("SHA-256").digest(transcript), + PAIRING_INFO.toByteArray(), + 32 + ) +} + +internal fun derivePairingCode(key: ByteArray, transcript: ByteArray): String { + val digest = hmac(key, "astra-phone-remote-v3-code".toByteArray() + transcript) + val value = ((digest[0].toLong() and 0xff) shl 24) or + ((digest[1].toLong() and 0xff) shl 16) or + ((digest[2].toLong() and 0xff) shl 8) or (digest[3].toLong() and 0xff) + return (value % 1_000_000L).toString().padStart(6, '0') +} + +internal fun derivePairingProof(key: ByteArray, transcript: ByteArray): String = + base64Url(hmac(key, "astra-phone-remote-v3-proof".toByteArray() + transcript)) + +private fun validateBaseUrl(baseUrl: String): URI { + val uri = URI(baseUrl) + if (!uri.scheme.equals("https", ignoreCase = true) || uri.host.isNullOrBlank()) { + throw IllegalArgumentException("Desktop Remote requires HTTPS.") + } + if (uri.userInfo != null || uri.port == 0 || uri.port < -1 || uri.port > 65_535) { + throw IllegalArgumentException("Invalid desktop base URL.") + } + if (uri.rawQuery != null || uri.rawFragment != null || (uri.path.isNotBlank() && uri.path != "/")) { + throw IllegalArgumentException("Invalid desktop base URL.") + } + return uri +} + +private fun sslContext(trustManager: X509TrustManager): SSLContext = + SSLContext.getInstance("TLSv1.2").apply { + init(null, arrayOf(trustManager), SecureRandom()) + } + +private fun pinnedHostnameVerifier(expected: String): HostnameVerifier = HostnameVerifier { _, session -> + sessionFingerprint(session) == normalizeFingerprint(expected) +} + +private fun sessionFingerprint(session: SSLSession): String? = try { + val leaf = session.peerCertificates.firstOrNull() as? X509Certificate + leaf?.let(::certificateFingerprint) +} catch (_: Throwable) { + null +} + +private fun openConnection( + baseUrl: String, + path: String, + fingerprint: String, + observingTrustManager: ObservingTrustManager? = null, +): HttpsURLConnection { + val base = validateBaseUrl(baseUrl) + if (!path.startsWith('/') || path.startsWith("//")) throw IllegalArgumentException("Invalid desktop request path.") + val url = base.resolve(path).toURL() + val trustManager = observingTrustManager ?: ExactFingerprintTrustManager(fingerprint) + return (url.openConnection() as HttpsURLConnection).apply { + sslSocketFactory = sslContext(trustManager).socketFactory + hostnameVerifier = if (observingTrustManager != null) { + HostnameVerifier { _, session -> + val observed = observingTrustManager.observedFingerprint + observed != null && sessionFingerprint(session) == observed + } + } else { + pinnedHostnameVerifier(fingerprint) + } + instanceFollowRedirects = false + useCaches = false + connectTimeout = 8_000 + readTimeout = 8_000 + } +} + +private fun readBounded(connection: HttpURLConnection): String { + val stream = if (connection.responseCode >= 400) connection.errorStream else connection.inputStream + if (stream == null) return "" + stream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val count = input.read(buffer) + if (count < 0) break + if (output.size() + count > MAX_RESPONSE_BYTES) throw IllegalStateException("Desktop response is too large.") + output.write(buffer, 0, count) + } + return output.toString(Charsets.UTF_8.name()) + } +} + +internal fun executeJson( + baseUrl: String, + path: String, + method: String, + body: String?, + token: String?, + fingerprint: String, + timeoutMs: Int, + observingTrustManager: ObservingTrustManager? = null, +): Pair { + if (method != "GET" && method != "POST") throw IllegalArgumentException("Unsupported desktop request method.") + val bodyBytes = body?.toByteArray(Charsets.UTF_8) + if (bodyBytes != null && bodyBytes.size > MAX_REQUEST_BYTES) throw IllegalArgumentException("Desktop request is too large.") + val connection = openConnection(baseUrl, path, fingerprint, observingTrustManager) + try { + connection.requestMethod = method + connection.connectTimeout = timeoutMs.coerceIn(1_000, 60_000) + connection.readTimeout = timeoutMs.coerceIn(1_000, 65_000) + connection.setRequestProperty("Accept", "application/json") + // Authorization is configured only on an exact-pin connection. TLS runs before HTTP bytes. + if (observingTrustManager == null && !token.isNullOrBlank()) { + connection.setRequestProperty("Authorization", "Bearer $token") + } + if (bodyBytes != null) { + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8") + connection.setFixedLengthStreamingMode(bodyBytes.size) + connection.outputStream.use { it.write(bodyBytes) } + } + val status = connection.responseCode + if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.") + return status to readBounded(connection) + } finally { + connection.disconnect() + } +} + +class AstraDesktopTransportModule : Module() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val streams = ConcurrentHashMap() + private val pairingAttempts = ConcurrentHashMap() + + override fun definition() = ModuleDefinition { + Name("AstraDesktopTransport") + Events("onDesktopTransportSse", "onDesktopTransportClosed") + + AsyncFunction("requestJson").Coroutine { baseUrl: String, path: String, method: String, + body: String?, token: String?, fingerprint: String, timeoutMs: Int -> + withContext(Dispatchers.IO) { + val (status, responseBody) = executeJson(baseUrl, path, method, body, token, fingerprint, timeoutMs) + mapOf("status" to status, "body" to responseBody) + } + } + + AsyncFunction("startEventStream") { baseUrl: String, token: String, fingerprint: String -> + validateBaseUrl(baseUrl) + val streamId = UUID.randomUUID().toString() + val job = scope.launch(start = CoroutineStart.LAZY) { runEventStream(streamId, baseUrl, token, fingerprint) } + streams[streamId] = job + job.start() + streamId + } + + Function("stopEventStream") { streamId: String -> + streams.remove(streamId)?.cancel() + } + + AsyncFunction("beginPinPairing").Coroutine { baseUrl: String, deviceName: String, clientLabel: String -> + withContext(Dispatchers.IO) { beginPinPairing(baseUrl, deviceName, clientLabel) } + } + + AsyncFunction("confirmPinPairing").Coroutine { attemptId: String, enteredCode: String -> + withContext(Dispatchers.IO) { confirmPinPairing(attemptId, enteredCode) } + } + + OnDestroy { + streams.values.forEach { it.cancel() } + streams.clear() + pairingAttempts.clear() + } + } + + private fun beginPinPairing(baseUrl: String, deviceName: String, clientLabel: String): Map { + val now = System.currentTimeMillis() + pairingAttempts.entries.removeIf { it.value.expiresAt <= now } + validateBaseUrl(baseUrl) + val keyPair = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair() + val phonePublicKey = base64Url(keyPair.public.encoded) + val observer = ObservingTrustManager() + val requestBody = JSONObject() + .put("deviceName", deviceName) + .put("clientLabel", clientLabel) + .put("phoneEphemeralPublicKey", phonePublicKey) + // Filled after the TLS handshake by the observing trust manager, before this credential-free body is sent. + val uri = validateBaseUrl(baseUrl) + val connection = openConnection(baseUrl, "/v1/pairing/pin-request", "", observer) + val bodyBytes: ByteArray + val responseBody: String + val status: Int + try { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setChunkedStreamingMode(4096) + connection.setRequestProperty("Accept", "application/json") + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8") + connection.connect() + val observed = observer.observedFingerprint ?: throw IllegalStateException("Desktop certificate was not observed.") + requestBody.put("observedCertificateFingerprint", displayFingerprint(observed)) + bodyBytes = requestBody.toString().toByteArray(Charsets.UTF_8) + connection.outputStream.use { it.write(bodyBytes) } + status = connection.responseCode + if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.") + responseBody = readBounded(connection) + } finally { + connection.disconnect() + } + if (status !in 200..299) throw IllegalStateException(JSONObject(responseBody).optString("error", "PIN pairing failed ($status).")) + val response = JSONObject(responseBody) + val certificateFingerprint = response.optString("certificateFingerprint") + val observed = observer.observedFingerprint ?: throw IllegalStateException("Desktop certificate was not observed.") + if (normalizeFingerprint(certificateFingerprint) != observed) throw IllegalStateException("Pairing transcript certificate mismatch.") + if (response.optInt("protocolVersion", 0) != 3) throw IllegalStateException("Desktop does not support secure protocol v3.") + val requestId = response.getString("requestId") + val desktopPublicKey = response.getString("desktopEphemeralPublicKey") + val identity = response.optJSONObject("identity") ?: JSONObject() + val endpointUuid = identity.optString("endpointUuid", "") + val desktopPort = if (uri.port > 0) uri.port else 443 + val transcript = JSONArray() + .put(3) + .put(requestId) + .put(phonePublicKey) + .put(desktopPublicKey) + .put(normalizeFingerprint(certificateFingerprint)) + .put(endpointUuid) + .put(desktopPort) + .toString().toByteArray(Charsets.UTF_8) + val pairingKey = derivePairingKey(keyPair.private, desktopPublicKey, transcript) + val code = derivePairingCode(pairingKey, transcript) + val attemptId = UUID.randomUUID().toString() + val expiresAt = response.optLong("expiresAt", 0L) + pairingAttempts[attemptId] = PairingAttempt( + baseUrl, requestId, expiresAt, displayFingerprint(certificateFingerprint), pairingKey, transcript, code + ) + return mapOf( + "attemptId" to attemptId, + "requestId" to requestId, + "expiresAt" to expiresAt, + "desktopName" to identity.optString("desktopName").ifBlank { null }, + "certificateFingerprint" to displayFingerprint(certificateFingerprint), + "protocolVersion" to 3, + ) + } + + private fun confirmPinPairing(attemptId: String, enteredCode: String): Map { + val attempt = pairingAttempts[attemptId] ?: throw IllegalStateException("PIN pairing attempt expired.") + if (System.currentTimeMillis() >= attempt.expiresAt) { + pairingAttempts.remove(attemptId) + throw IllegalStateException("PIN pairing attempt expired.") + } + if (enteredCode != attempt.code) throw IllegalArgumentException("Wrong PIN. Try again.") + val proof = derivePairingProof(attempt.key, attempt.transcript) + val requestBody = JSONObject().put("requestId", attempt.requestId).put("proof", proof).toString() + val (status, responseBody) = executeJson( + attempt.baseUrl, "/v1/pairing/pin-confirm", "POST", requestBody, null, + attempt.certificateFingerprint, 8_000 + ) + if (status !in 200..299) throw IllegalStateException(JSONObject(responseBody).optString("error", "PIN confirmation failed ($status).")) + val response = JSONObject(responseBody) + if (normalizeFingerprint(response.optString("certificateFingerprint")) != normalizeFingerprint(attempt.certificateFingerprint)) { + throw IllegalStateException("Pairing transcript certificate mismatch.") + } + val sealed = response.getJSONObject("sealed") + val nonce = decodeBase64Url(sealed.getString("nonce")) + val encrypted = decodeBase64Url(sealed.getString("ciphertext")) + decodeBase64Url(sealed.getString("authTag")) + val aad = PAIRING_AAD_PREFIX.toByteArray() + MessageDigest.getInstance("SHA-256").digest(attempt.transcript) + val plaintext = Cipher.getInstance("AES/GCM/NoPadding").run { + init(Cipher.DECRYPT_MODE, SecretKeySpec(attempt.key, "AES"), GCMParameterSpec(128, nonce)) + updateAAD(aad) + doFinal(encrypted) + } + pairingAttempts.remove(attemptId) + val credentials = JSONObject(String(plaintext, Charsets.UTF_8)) + if (normalizeFingerprint(credentials.getString("certificateFingerprint")) != normalizeFingerprint(attempt.certificateFingerprint)) { + throw IllegalStateException("Sealed credential certificate mismatch.") + } + return mapOf( + "controlToken" to credentials.getString("controlToken"), + "syncToken" to credentials.getString("syncToken"), + "deviceId" to credentials.optString("deviceId").ifBlank { null }, + "issuedAt" to credentials.getLong("issuedAt"), + "identityJson" to credentials.getJSONObject("identity").toString(), + "certificateFingerprint" to attempt.certificateFingerprint, + ) + } + + private suspend fun runEventStream(streamId: String, baseUrl: String, token: String, fingerprint: String) { + var unauthorized = false + var message = "Desktop event stream closed." + val connection = openConnection(baseUrl, "/v1/events", fingerprint) + try { + connection.requestMethod = "GET" + connection.readTimeout = 65_000 + connection.setRequestProperty("Accept", "text/event-stream") + connection.setRequestProperty("Authorization", "Bearer $token") + val status = connection.responseCode + if (status in 300..399) throw IllegalStateException("Desktop Remote refuses redirects.") + if (status == 401) { + unauthorized = true + message = "Desktop pairing was revoked." + return + } + if (status !in 200..299) throw IllegalStateException("Desktop event stream failed ($status).") + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + var eventName = "message" + val data = mutableListOf() + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) { + if (data.isNotEmpty()) sendEvent("onDesktopTransportSse", mapOf( + "streamId" to streamId, "event" to eventName, "data" to data.joinToString("\n") + )) + eventName = "message" + data.clear() + } else if (line.startsWith("event:")) { + eventName = line.substring(6).trim() + } else if (line.startsWith("data:")) { + data.add(line.substring(5).trimStart()) + } + } + } + } catch (_: CancellationException) { + message = "" + } catch (error: Throwable) { + message = error.message ?: "Desktop event stream failed." + } finally { + connection.disconnect() + streams.remove(streamId) + sendEvent("onDesktopTransportClosed", mapOf( + "streamId" to streamId, "unauthorized" to unauthorized, "message" to message + )) + } + } +} diff --git a/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt b/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt new file mode 100644 index 0000000..808e511 --- /dev/null +++ b/modules/astra-desktop-transport/android/src/test/java/expo/modules/astradesktoptransport/AstraDesktopTransportModuleTest.kt @@ -0,0 +1,175 @@ +package expo.modules.astradesktoptransport + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import java.net.InetSocketAddress +import java.net.InetAddress +import java.security.KeyFactory +import java.security.KeyStore +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import java.util.concurrent.CopyOnWriteArrayList +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import kotlin.concurrent.thread + +class AstraDesktopTransportModuleTest { + private var server: SSLServerSocket? = null + private var serverThread: Thread? = null + + @After fun tearDown() { + server?.close() + serverThread?.join(1_000) + } + + @Test fun correctPinSendsAuthorizationAndReturnsJson() { + val fixture = startServer(200, "{\"ok\":true}") + val (status, body) = executeJson( + fixture.baseUrl, "/v1/identity", "GET", null, "control-secret", + fixture.fingerprint, 5_000 + ) + assertEquals(200, status) + assertEquals("{\"ok\":true}", body) + assertEquals(listOf("Bearer control-secret"), fixture.authorizationHeaders) + } + + @Test fun wrongCertificateFailsBeforeAuthorizationOrPayload() { + val fixture = startServer(200, "{\"ok\":true}") + val wrongFingerprint = "00".repeat(32) + assertThrows(Throwable::class.java) { + executeJson( + fixture.baseUrl, "/v1/sync/apply", "POST", "{\"private\":\"sync-payload\"}", + "sync-secret", wrongFingerprint, 5_000 + ) + } + assertEquals(emptyList(), fixture.authorizationHeaders) + assertEquals(emptyList(), fixture.requestBodies) + } + + @Test fun redirectIsNeverFollowed() { + val fixture = startServer(302, "") + assertThrows(IllegalStateException::class.java) { + executeJson( + fixture.baseUrl, "/v1/identity", "GET", null, null, + fixture.fingerprint, 5_000 + ) + } + assertEquals(1, fixture.requestCount()) + } + + @Test fun pairingTranscriptMatchesTheDesktopProtocolVector() { + val privateKey = KeyFactory.getInstance("EC").generatePrivate( + PKCS8EncodedKeySpec(Base64.getDecoder().decode(KEY_DER_BASE64)) + ) + val transcript = "[3,\"pairing-test\",\"$PUBLIC_KEY_BASE64URL\",\"$PUBLIC_KEY_BASE64URL\",\"$CERT_FINGERPRINT\",\"endpoint-test\",38402]" + .toByteArray() + val key = derivePairingKey(privateKey, PUBLIC_KEY_BASE64URL, transcript) + assertArrayEquals(hex("f6755aa6d05cb1f5ce79f7d01c716feaa97efa552521fc8b5841877c859c9038"), key) + assertEquals("824321", derivePairingCode(key, transcript)) + assertEquals("8VqyqyI_ndBul51SD2OU4eia4e_SGr2pu_0EdJ-iJVw", derivePairingProof(key, transcript)) + } + + private fun hex(value: String): ByteArray = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private data class Fixture( + val baseUrl: String, + val fingerprint: String, + val authorizationHeaders: CopyOnWriteArrayList, + val requestBodies: CopyOnWriteArrayList, + val requestCount: () -> Int, + ) + + private fun startServer(status: Int, responseBody: String): Fixture { + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(CERT_DER_BASE64.byteInputStream().let(Base64.getDecoder()::wrap)) as X509Certificate + val privateKey = KeyFactory.getInstance("EC").generatePrivate( + PKCS8EncodedKeySpec(Base64.getDecoder().decode(KEY_DER_BASE64)) + ) + val password = "test-password".toCharArray() + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setKeyEntry("server", privateKey, password, arrayOf(certificate)) + } + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(keyStore, password) + } + val context = SSLContext.getInstance("TLSv1.2").apply { + init(keyManagers.keyManagers, null, SecureRandom()) + } + val auth = CopyOnWriteArrayList() + val bodies = CopyOnWriteArrayList() + var count = 0 + server = context.serverSocketFactory.createServerSocket( + 0, 16, InetAddress.getByName("127.0.0.1") + ) as SSLServerSocket + val socketServer = server!! + serverThread = thread(name = "astra-desktop-transport-test-server", isDaemon = true) { + while (!socketServer.isClosed) { + try { + socketServer.accept().use { socket -> + val input = socket.getInputStream().bufferedReader(Charsets.UTF_8) + val requestLine = input.readLine() ?: return@use + if (requestLine.isBlank()) return@use + val headers = mutableMapOf() + while (true) { + val line = input.readLine() ?: break + if (line.isEmpty()) break + val separator = line.indexOf(':') + if (separator > 0) headers[line.substring(0, separator).lowercase()] = line.substring(separator + 1).trim() + } + count += 1 + headers["authorization"]?.let(auth::add) + val length = headers["content-length"]?.toIntOrNull() ?: 0 + if (length > 0) { + val body = CharArray(length) + var offset = 0 + while (offset < length) { + val read = input.read(body, offset, length - offset) + if (read < 0) break + offset += read + } + if (offset > 0) bodies.add(String(body, 0, offset)) + } + val bytes = responseBody.toByteArray() + val reason = if (status in 300..399) "Found" else "OK" + val response = buildString { + append("HTTP/1.1 $status $reason\r\n") + append("Content-Type: application/json\r\n") + append("Content-Length: ${bytes.size}\r\n") + if (status in 300..399) append("Location: /redirected\r\n") + append("Connection: close\r\n\r\n") + }.toByteArray() + socket.getOutputStream().use { output -> + output.write(response) + output.write(bytes) + output.flush() + } + } + } catch (_: Throwable) { + if (!socketServer.isClosed) continue + } + } + } + val fingerprint = MessageDigest.getInstance("SHA-256") + .digest(certificate.encoded) + .joinToString("") { "%02X".format(it) } + return Fixture( + "https://127.0.0.1:${(server!!.localSocketAddress as InetSocketAddress).port}", fingerprint, auth, bodies, { count } + ) + } + + companion object { + private const val CERT_DER_BASE64 = "MIIBgzCCASmgAwIBAgIQb1XrfH3Pjja0eaxzR9J2NTAKBggqhkjOPQQDAjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMjYwNzEyMjMwMzE2WhcNMzYwNzEwMjMwMzE2WjAUMRIwEAYDVQQDEwlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQyKPCr7mEqvp/LVhnUgexpbTOPcq09dRNMfeF96uSbPXaL2E7qktUQFLYNm6A0kv3i6XK0aZiIcjUHrhNiJkjKo10wWzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIChDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUT3YyZJjuQU9t1PTIM6ShuJX1j6QwCgYIKoZIzj0EAwIDSAAwRQIgcoTHim922rYnRyOduB8pMQNplwQ4N33xxV3BmphP09ICIQCV+FdMVUE8ykFdluehZ63HXYa+UUJOk9v3K8LKdS6HKQ==" + private const val KEY_DER_BASE64 = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgC2+uZe2nmL2ChsSJDBMu9bkQu9NSlaNn0eCuf1CxcPqhRANCAAQyKPCr7mEqvp/LVhnUgexpbTOPcq09dRNMfeF96uSbPXaL2E7qktUQFLYNm6A0kv3i6XK0aZiIcjUHrhNiJkjK" + private const val PUBLIC_KEY_BASE64URL = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMijwq-5hKr6fy1YZ1IHsaW0zj3KtPXUTTH3hferkmz12i9hO6pLVEBS2DZugNJL94ulytGmYiHI1B64TYiZIyg" + private const val CERT_FINGERPRINT = "7FE09E5A9EE622987491D2877F9E3661AAA0ABC253CC3220BCC4AA9E28C3D14F" + } +} diff --git a/modules/astra-desktop-transport/expo-module.config.json b/modules/astra-desktop-transport/expo-module.config.json new file mode 100644 index 0000000..573fb92 --- /dev/null +++ b/modules/astra-desktop-transport/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.astradesktoptransport.AstraDesktopTransportModule"] + } +} diff --git a/modules/astra-desktop-transport/index.ts b/modules/astra-desktop-transport/index.ts new file mode 100644 index 0000000..079b504 --- /dev/null +++ b/modules/astra-desktop-transport/index.ts @@ -0,0 +1,58 @@ +import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'; + +export interface AstraDesktopTransportResponse { + status: number; + body: string; +} + +export interface AstraDesktopPinAttempt { + attemptId: string; + requestId: string; + expiresAt: number; + desktopName: string | null; + certificateFingerprint: string; + protocolVersion: number; +} + +export interface AstraDesktopPinResult { + controlToken: string; + syncToken: string; + deviceId: string | null; + issuedAt: number; + identityJson: string; + certificateFingerprint: string; +} + +type AstraDesktopTransportEvents = { + onDesktopTransportSse: (event: { + streamId: string; + event: string; + data: string; + }) => void; + onDesktopTransportClosed: (event: { + streamId: string; + unauthorized: boolean; + message: string; + }) => void; +}; + +declare class AstraDesktopTransportModuleType extends NativeModule { + requestJson( + baseUrl: string, + path: string, + method: 'GET' | 'POST', + body: string | null, + token: string | null, + certificateFingerprint: string, + timeoutMs: number + ): Promise; + startEventStream(baseUrl: string, token: string, certificateFingerprint: string): Promise; + stopEventStream(streamId: string): void; + beginPinPairing(baseUrl: string, deviceName: string, clientLabel: string): Promise; + confirmPinPairing(attemptId: string, enteredCode: string): Promise; +} + +export const AstraDesktopTransport = + requireOptionalNativeModule('AstraDesktopTransport'); + +export const desktopPinnedTransportAvailable = AstraDesktopTransport != null; diff --git a/package.json b/package.json index 0397641..e331bc2 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", "test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts", - "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/shared/sync/conflictPreview.test.mts", + "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts", "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 81449e6..8699ccc 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -34,6 +34,7 @@ import { setDesktopRemoteMediaSession, subscribeDesktopRemoteMediaSessionCommands, } from '@/services/desktopRemoteMediaSession'; +import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; import { getDesktopRemoteConnection, setDesktopRemoteConnection, @@ -170,6 +171,8 @@ const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000; function DesktopSyncAutoTrigger() { const connectionState = useDesktopRemoteStore((s) => s.connectionState); const discovered = useDesktopRemoteStore((s) => s.discovered); + const desktopSyncHydrated = useDesktopSyncStore((s) => s.hydrated); + const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled); useEffect(() => { void useDesktopSyncStore.getState().hydrate(); @@ -182,6 +185,8 @@ function DesktopSyncAutoTrigger() { let startupRetryTimer: ReturnType | null = null; const onActive = () => { void (async () => { + const sync = useDesktopSyncStore.getState(); + if (!sync.hydrated || !sync.desktopSyncEnabled) return; const connection = await getDesktopRemoteConnection(); if (!connection) return; useDesktopSyncStore.getState().maybeAutoSync('foreground'); @@ -210,31 +215,41 @@ function DesktopSyncAutoTrigger() { subscription.remove(); if (burstTimer !== null) clearTimeout(burstTimer); if (startupRetryTimer !== null) clearTimeout(startupRetryTimer); + if (!useDesktopSyncStore.getState().desktopSyncEnabled) { + void useDesktopRemoteStore.getState().stopDiscovery(); + } }; - }, []); + }, [desktopSyncEnabled, desktopSyncHydrated]); // Desktop-initiated "Sync now" pickup: a cheap identity poll while // foregrounded (the SSE nudge only reaches us while the remote screen's // stream happens to be connected). fetchDesktopRemoteIdentity swallows // errors, so a powered-off desktop costs one timed-out request per minute. useEffect(() => { + if (!desktopSyncHydrated || !desktopSyncEnabled) return; const timer = setInterval(() => { if (AppState.currentState !== 'active') return; void (async () => { + const sync = useDesktopSyncStore.getState(); + if (!sync.hydrated || !sync.desktopSyncEnabled) return; const connection = await getDesktopRemoteConnection(); if (!connection) return; - const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + const identity = await fetchDesktopRemoteIdentity( + connection.baseUrl, + connection.certificateFingerprint + ); if (identity?.syncRequestedAt) { useDesktopSyncStore.getState().handleSyncRequest(); } })(); }, DESKTOP_SYNC_REQUEST_POLL_MS); return () => clearInterval(timer); - }, []); + }, [desktopSyncEnabled, desktopSyncHydrated]); // Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves) // and trigger a sync. useEffect(() => { + if (!desktopSyncHydrated || !desktopSyncEnabled) return; if (discovered.length === 0) return; void (async () => { const connection = await getDesktopRemoteConnection(); @@ -242,6 +257,15 @@ function DesktopSyncAutoTrigger() { const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid); if (!match) return; if (match.baseUrl && match.baseUrl !== connection.baseUrl) { + const identity = await fetchDesktopRemoteIdentity( + match.baseUrl, + connection.certificateFingerprint + ); + if (!identity || !identityMatchesPinnedConnection( + connection.endpointUuid, + identity.protocolVersion, + identity.endpointUuid + )) return; const updated = { ...connection, baseUrl: match.baseUrl }; await setDesktopRemoteConnection(updated); if (useDesktopRemoteStore.getState().connection) { @@ -250,14 +274,14 @@ function DesktopSyncAutoTrigger() { } useDesktopSyncStore.getState().maybeAutoSync('discovery'); })(); - }, [discovered]); + }, [desktopSyncEnabled, desktopSyncHydrated, discovered]); // The remote screen connected — the desktop is definitely reachable. useEffect(() => { - if (connectionState === 'connected') { + if (desktopSyncHydrated && desktopSyncEnabled && connectionState === 'connected') { useDesktopSyncStore.getState().maybeAutoSync('connected'); } - }, [connectionState]); + }, [desktopSyncEnabled, desktopSyncHydrated, connectionState]); return null; } diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx index 07a0821..408e87d 100644 --- a/src/app/desktop-remote.tsx +++ b/src/app/desktop-remote.tsx @@ -225,7 +225,20 @@ export default function DesktopRemoteScreen() { const ripple = useRipple(); const colors = useColors(); const router = useRouter(); - const { pair } = useLocalSearchParams<{ pair?: string }>(); + const pairingParams = useLocalSearchParams<{ + pair?: string; + baseUrl?: string; + ticket?: string; + endpointUuid?: string; + fingerprint?: string; + protocolVersion?: string; + }>(); + const pairingRoutePair = pairingParams.pair; + const pairingRouteBaseUrl = pairingParams.baseUrl; + const pairingRouteTicket = pairingParams.ticket; + const pairingRouteEndpointUuid = pairingParams.endpointUuid; + const pairingRouteFingerprint = pairingParams.fingerprint; + const pairingRouteProtocolVersion = pairingParams.protocolVersion; const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const initialized = useDesktopRemoteStore((s) => s.initialized); @@ -253,7 +266,6 @@ export default function DesktopRemoteScreen() { const [pinInput, setPinInput] = useState(''); const [pinClock, setPinClock] = useState(() => Date.now()); const [manualBaseUrl, setManualBaseUrl] = useState(''); - const [manualTicket, setManualTicket] = useState(''); useEffect(() => { void init(); @@ -267,11 +279,35 @@ export default function DesktopRemoteScreen() { }, [startDiscovery, stopDiscovery]); useEffect(() => { - if (typeof pair === 'string' && pair.trim()) { - void pairFromInput(pair); + if (typeof pairingRoutePair === 'string' && pairingRoutePair.trim()) { + void pairFromInput(pairingRoutePair); router.setParams({ pair: undefined }); + return; } - }, [pair, pairFromInput, router]); + if ( + typeof pairingRouteBaseUrl === 'string' && typeof pairingRouteTicket === 'string' && + typeof pairingRouteEndpointUuid === 'string' && typeof pairingRouteFingerprint === 'string' && + pairingRouteProtocolVersion === '3' + ) { + const url = new URL('astra://desktop-remote'); + url.searchParams.set('baseUrl', pairingRouteBaseUrl); + url.searchParams.set('ticket', pairingRouteTicket); + url.searchParams.set('endpointUuid', pairingRouteEndpointUuid); + url.searchParams.set('fingerprint', pairingRouteFingerprint); + url.searchParams.set('protocolVersion', '3'); + void pairFromInput(url.toString()); + router.setParams({ baseUrl: undefined, ticket: undefined, endpointUuid: undefined, fingerprint: undefined, protocolVersion: undefined }); + } + }, [ + pairFromInput, + pairingRouteBaseUrl, + pairingRouteEndpointUuid, + pairingRouteFingerprint, + pairingRoutePair, + pairingRouteProtocolVersion, + pairingRouteTicket, + router, + ]); useEffect(() => { if (!pinPairing) return undefined; @@ -453,40 +489,31 @@ export default function DesktopRemoteScreen() { Manual fallback - Enter the desktop URL and pairing code from Astra Desktop. + Enter the Astra Desktop HTTPS URL, then compare the six-digit code shown on both devices. - void pairManual(manualBaseUrl, manualTicket)} + disabled={!manualBaseUrl.trim()} + onPress={() => void pairManual(manualBaseUrl)} > - Pair manually + Request secure PIN diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx index 228b88e..d91d019 100644 --- a/src/app/desktop-sync.tsx +++ b/src/app/desktop-sync.tsx @@ -140,9 +140,9 @@ export default function DesktopSyncScreen() { const lastSummary = useDesktopSyncStore((s) => s.lastSummary); const conflicts = useDesktopSyncStore((s) => s.conflicts); const errorMessage = useDesktopSyncStore((s) => s.errorMessage); - const autoSyncEnabled = useDesktopSyncStore((s) => s.autoSyncEnabled); + const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled); const syncNow = useDesktopSyncStore((s) => s.syncNow); - const setAutoSyncEnabled = useDesktopSyncStore((s) => s.setAutoSyncEnabled); + const setDesktopSyncEnabled = useDesktopSyncStore((s) => s.setDesktopSyncEnabled); const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict); const [connection, setConnection] = useState(null); @@ -229,8 +229,8 @@ export default function DesktopSyncScreen() { void syncNow()} accessibilityLabel="Sync favorites and playlists now" > @@ -259,15 +259,15 @@ export default function DesktopSyncScreen() { - Sync automatically + Desktop Sync - Sync when this desktop appears on the network or the app returns to the - foreground. Manual and desktop-requested syncs always work. + Allow favorites and playlists to sync securely with this desktop on any + network. Turn this off to suppress every sync trigger without forgetting it. void setAutoSyncEnabled(value)} + value={desktopSyncEnabled} + onValueChange={(value) => void setDesktopSyncEnabled(value)} trackColor={{ false: colors.glassBorder, true: colors.accent }} thumbColor={colors.textPrimary} /> diff --git a/src/services/desktopRemoteClient.ts b/src/services/desktopRemoteClient.ts index df04da6..6835f48 100644 --- a/src/services/desktopRemoteClient.ts +++ b/src/services/desktopRemoteClient.ts @@ -1,4 +1,8 @@ import * as Device from 'expo-device'; +import { + AstraDesktopTransport, + desktopPinnedTransportAvailable, +} from '../../modules/astra-desktop-transport'; import type { DesktopRemoteControlCommand, DesktopRemoteIdentity, @@ -26,7 +30,7 @@ interface JsonRequestOptions { token?: string | null; body?: unknown; timeoutMs?: number; - signal?: AbortSignal; + fingerprint: string; } export class DesktopRemoteHttpError extends Error { @@ -40,52 +44,26 @@ export class DesktopRemoteHttpError extends Error { } } -function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - const abort = () => controller.abort(); - if (parentSignal) { - if (parentSignal.aborted) controller.abort(); - else parentSignal.addEventListener('abort', abort, { once: true }); - } - - controller.signal.addEventListener( - 'abort', - () => { - clearTimeout(timer); - parentSignal?.removeEventListener('abort', abort); - }, - { once: true } - ); - - return controller.signal; -} - async function fetchJson( baseUrl: string, path: string, - options: JsonRequestOptions = {} + options: JsonRequestOptions ): Promise { - const headers: Record = { - Accept: 'application/json', - }; - let body: string | undefined; - if (options.body !== undefined) { - headers['Content-Type'] = 'application/json; charset=utf-8'; - body = JSON.stringify(options.body); + if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) { + throw new Error('Secure Desktop Remote transport is unavailable on this device.'); } - if (options.token) headers.Authorization = `Bearer ${options.token}`; - - const response = await fetch(`${baseUrl}${path}`, { - method: options.method ?? (body ? 'POST' : 'GET'), - headers, + const body = options.body === undefined ? null : JSON.stringify(options.body); + const response = await AstraDesktopTransport.requestJson( + baseUrl, + path, + options.method ?? (body ? 'POST' : 'GET'), body, - cache: 'no-store', - signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) { + options.token ?? null, + options.fingerprint, + options.timeoutMs ?? REQUEST_TIMEOUT_MS + ); + const payload = response.body ? JSON.parse(response.body) as unknown : null; + if (response.status < 200 || response.status >= 300) { const message = payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string' ? payload.error @@ -127,9 +105,12 @@ export function defaultDesktopRemoteDeviceName(): string { return model ? `${model} Remote` : 'Astra Mobile Remote'; } -export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise { +export async function fetchDesktopRemoteIdentity( + baseUrl: string, + certificateFingerprint: string +): Promise { try { - const payload = await fetchJson(baseUrl, '/v1/identity'); + const payload = await fetchJson(baseUrl, '/v1/identity', { fingerprint: certificateFingerprint }); return normalizeIdentity(payload); } catch { return null; @@ -139,9 +120,11 @@ export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise { const payload = await fetchJson>(baseUrl, '/v1/pairing/claim', { + fingerprint: certificateFingerprint, method: 'POST', body: { ticket, @@ -163,58 +146,67 @@ export async function requestDesktopRemotePinPairing( baseUrl: string, deviceName: string = defaultDesktopRemoteDeviceName() ): Promise { - const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-request', { - method: 'POST', - body: { - deviceName, - clientLabel: clientLabel(), - }, - }); + if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) { + throw new Error('Secure Desktop Remote transport is unavailable on this device.'); + } + const payload = await AstraDesktopTransport.beginPinPairing(baseUrl, deviceName, clientLabel()); return { - requestId: String(payload.requestId ?? ''), - pollToken: String(payload.pollToken ?? ''), - expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0, - deviceName: String(payload.deviceName ?? deviceName), - clientLabel: String(payload.clientLabel ?? clientLabel()), - identity: normalizeIdentity(payload.identity ?? payload), + attemptId: payload.attemptId, + requestId: payload.requestId, + pollToken: '', + expiresAt: payload.expiresAt, + deviceName, + clientLabel: clientLabel(), + identity: { endpointUuid: null, desktopName: payload.desktopName, protocolVersion: 3 }, + certificateFingerprint: payload.certificateFingerprint, + protocolVersion: 3, }; } export async function confirmDesktopRemotePinPairing( - baseUrl: string, - requestId: string, + attemptId: string, pin: string ): Promise { - const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-confirm', { - method: 'POST', - body: { - requestId, - pin, - }, - }); - const state = typeof payload.state === 'string' ? payload.state : 'approved'; + if (!desktopPinnedTransportAvailable || !AstraDesktopTransport) { + throw new Error('Secure Desktop Remote transport is unavailable on this device.'); + } + const payload = await AstraDesktopTransport.confirmPinPairing(attemptId, pin); return { - state: state as DesktopRemotePairingStatus['state'], - expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0, - token: typeof payload.token === 'string' ? payload.token : undefined, - deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null, - identity: normalizeIdentity(payload.identity ?? payload), + state: 'approved', + expiresAt: 0, + token: payload.controlToken, + controlToken: payload.controlToken, + syncToken: payload.syncToken, + issuedAt: payload.issuedAt, + scopes: ['control', 'sync'], + certificateFingerprint: payload.certificateFingerprint, + deviceId: payload.deviceId, + identity: normalizeIdentity(JSON.parse(payload.identityJson)), }; } export async function fetchDesktopRemotePairingStatus( baseUrl: string, - pollToken: string + pollToken: string, + certificateFingerprint: string ): Promise { const payload = await fetchJson>( baseUrl, - `/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}` + `/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`, + { fingerprint: certificateFingerprint } ); const state = typeof payload.state === 'string' ? payload.state : 'pending'; return { state: state as DesktopRemotePairingStatus['state'], expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0, token: typeof payload.token === 'string' ? payload.token : undefined, + controlToken: typeof payload.controlToken === 'string' ? payload.controlToken : undefined, + syncToken: typeof payload.syncToken === 'string' ? payload.syncToken : undefined, + issuedAt: typeof payload.issuedAt === 'number' ? payload.issuedAt : undefined, + scopes: Array.isArray(payload.scopes) + ? payload.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync') + : undefined, + certificateFingerprint: typeof payload.certificateFingerprint === 'string' ? payload.certificateFingerprint : undefined, deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null, identity: normalizeIdentity(payload.identity ?? payload), }; @@ -223,24 +215,27 @@ export async function fetchDesktopRemotePairingStatus( export async function fetchDesktopRemoteNowPlaying( baseUrl: string, token: string, + certificateFingerprint: string, inlineArtwork = false ): Promise { return fetchJson( baseUrl, `/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`, - { token } + { token, fingerprint: certificateFingerprint } ); } export async function sendDesktopRemoteControl( baseUrl: string, token: string, + certificateFingerprint: string, command: DesktopRemoteControlCommand, time?: number ): Promise { await fetchJson<{ ok: true }>(baseUrl, '/v1/control', { method: 'POST', token, + fingerprint: certificateFingerprint, body: command === 'seek' ? { command, time } : { command }, }); } @@ -248,38 +243,87 @@ export async function sendDesktopRemoteControl( export async function sendDesktopRemotePlayQueueItem( baseUrl: string, token: string, + certificateFingerprint: string, queueId: string ): Promise { await fetchJson<{ ok: true }>(baseUrl, '/v1/control', { method: 'POST', token, + fingerprint: certificateFingerprint, body: { command: 'play-queue-item', queueId }, }); } export async function fetchDesktopRemoteQueue( baseUrl: string, - token: string + token: string, + certificateFingerprint: string ): Promise { - return fetchJson(baseUrl, '/v1/queue', { token }); + return fetchJson(baseUrl, '/v1/queue', { token, fingerprint: certificateFingerprint }); +} + +export interface DesktopRemoteSessionInfo { + deviceId: string; + scopes: ('control' | 'sync')[]; + issuedAt: number; + rotatedAt: number; + rotateAfter: number; + rotateRequiredAt: number; + expiresAt: number; + rotationRequired: boolean; + usingPreviousCredential: boolean; +} + +export interface DesktopRemoteRotatedCredentials { + controlToken: string; + syncToken: string; + issuedAt: number; + previousValidUntil: number; + rotateAfter: number; +} + +export async function inspectDesktopRemoteSession( + baseUrl: string, + controlToken: string, + certificateFingerprint: string +): Promise { + return fetchJson(baseUrl, '/v1/session', { + token: controlToken, + fingerprint: certificateFingerprint, + }); +} + +export async function rotateDesktopRemoteCredentials( + baseUrl: string, + controlToken: string, + certificateFingerprint: string +): Promise { + return fetchJson(baseUrl, '/v1/session/rotate', { + method: 'POST', + token: controlToken, + fingerprint: certificateFingerprint, + body: {}, + }); } // ── Favorites/playlists LAN sync (protocolVersion >= 2) ────────────────────── // Sync payloads can carry thousands of favorites/playlist entries, so both // calls get generous timeouts compared to the 8 s control default. -export async function fetchDesktopSyncState(baseUrl: string, token: string): Promise { - return fetchJson(baseUrl, '/v1/sync/state', { token, timeoutMs: 30_000 }); +export async function fetchDesktopSyncState(baseUrl: string, token: string, certificateFingerprint: string): Promise { + return fetchJson(baseUrl, '/v1/sync/state', { token, fingerprint: certificateFingerprint, timeoutMs: 30_000 }); } export async function postDesktopSyncApply( baseUrl: string, token: string, + certificateFingerprint: string, payload: DesktopSyncApplyPayload ): Promise { return fetchJson(baseUrl, '/v1/sync/apply', { method: 'POST', token, + fingerprint: certificateFingerprint, body: payload, timeoutMs: 60_000, }); @@ -288,11 +332,13 @@ export async function postDesktopSyncApply( export async function postDesktopSyncConflicts( baseUrl: string, token: string, + certificateFingerprint: string, payload: DesktopSyncConflictReportPayload ): Promise { await fetchJson<{ ok: true }>(baseUrl, '/v1/sync/conflicts', { method: 'POST', token, + fingerprint: certificateFingerprint, body: payload, }); } @@ -307,103 +353,53 @@ export type DesktopRemoteSseHandlers = { onError?: (error: unknown) => void; }; -function processSseChunk( - buffer: { value: string }, - chunk: string, - onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void, - onQueue?: (queue: DesktopRemoteQueueSnapshot) => void, - onSyncRequest?: () => void -): void { - buffer.value += chunk.replace(/\r/g, ''); - let boundary = buffer.value.indexOf('\n\n'); - while (boundary !== -1) { - const raw = buffer.value.slice(0, boundary); - buffer.value = buffer.value.slice(boundary + 2); - let eventName = 'message'; - const data: string[] = []; - for (const line of raw.split('\n')) { - if (!line || line.startsWith(':')) continue; - if (line.startsWith('event:')) { - eventName = line.slice(6).trim(); - continue; - } - if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); - } - if (eventName === 'now-playing' && data.length > 0) { - try { - onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot); - } catch { - // Ignore a malformed event; polling/reconnect will correct the UI. - } - } else if (eventName === 'queue' && data.length > 0 && onQueue) { - try { - onQueue(JSON.parse(data.join('\n')) as DesktopRemoteQueueSnapshot); - } catch { - // Ignore a malformed event; the on-demand queue fetch will correct it. - } - } else if (eventName === 'sync-request' && onSyncRequest) { - onSyncRequest(); - } - boundary = buffer.value.indexOf('\n\n'); - } -} - export function startDesktopRemoteEventStream( baseUrl: string, token: string, + certificateFingerprint: string, handlers: DesktopRemoteSseHandlers ): () => void { - const controller = new AbortController(); let closed = false; + let streamId: string | null = null; + const transport = AstraDesktopTransport; + if (!transport) { + handlers.onError?.(new Error('Secure Desktop Remote transport is unavailable on this device.')); + return () => {}; + } - void (async () => { - try { - const response = await fetch(`${baseUrl}/v1/events`, { - headers: { - Accept: 'text/event-stream', - Authorization: `Bearer ${token}`, - }, - cache: 'no-store', - signal: controller.signal, - }); - if (response.status === 401) { - handlers.onUnauthorized(); - return; - } - const body = response.body as unknown as { - getReader?: () => { - read: () => Promise<{ done: boolean; value?: Uint8Array }>; - }; - } | null; - if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`); - - const reader = body.getReader(); - const decoder = new TextDecoder(); - const buffer = { value: '' }; - while (!closed) { - const next = await reader.read(); - if (next.done) break; - if (next.value) { - processSseChunk( - buffer, - decoder.decode(next.value, { stream: true }), - handlers.onSnapshot, - handlers.onQueue, - handlers.onSyncRequest - ); - } - } - processSseChunk(buffer, decoder.decode(), handlers.onSnapshot, handlers.onQueue, handlers.onSyncRequest); - if (!closed) handlers.onDisconnect(); - } catch (error) { - if (closed || controller.signal.aborted) return; - handlers.onError?.(error); + const eventSubscription = transport.addListener('onDesktopTransportSse', (event) => { + if (closed || !streamId || event.streamId !== streamId) return; + if (event.event === 'now-playing') { + try { handlers.onSnapshot(JSON.parse(event.data) as DesktopRemoteNowPlayingSnapshot); } catch { /* ignore */ } + } else if (event.event === 'queue' && handlers.onQueue) { + try { handlers.onQueue(JSON.parse(event.data) as DesktopRemoteQueueSnapshot); } catch { /* ignore */ } + } else if (event.event === 'sync-request') { + handlers.onSyncRequest?.(); + } + }); + const closedSubscription = transport.addListener('onDesktopTransportClosed', (event) => { + if (closed || !streamId || event.streamId !== streamId) return; + if (event.unauthorized) handlers.onUnauthorized(); + else if (event.message) { + handlers.onError?.(new Error(event.message)); handlers.onDisconnect(); } - })(); + }); + + void transport.startEventStream(baseUrl, token, certificateFingerprint).then((id) => { + streamId = id; + if (closed) transport.stopEventStream(id); + }).catch((error) => { + if (closed) return; + handlers.onError?.(error); + handlers.onDisconnect(); + }); return () => { + if (closed) return; closed = true; - controller.abort(); + eventSubscription.remove(); + closedSubscription.remove(); + if (streamId) transport.stopEventStream(streamId); }; } diff --git a/src/services/desktopRemoteCredentials.ts b/src/services/desktopRemoteCredentials.ts index 9348941..5c22c45 100644 --- a/src/services/desktopRemoteCredentials.ts +++ b/src/services/desktopRemoteCredentials.ts @@ -1,25 +1,70 @@ import * as SecureStore from 'expo-secure-store'; import type { DesktopRemoteConnection } from '@/types/desktopRemote'; -const CONNECTION_KEY = 'desktop_remote_connection_v1'; -const TOKEN_KEY = 'desktop_remote_token_v1'; +const CONNECTION_KEY = 'desktop_remote_connection_v3'; +const CREDENTIALS_KEY = 'desktop_remote_credentials_v3'; +const LEGACY_CONNECTION_KEY = 'desktop_remote_connection_v1'; +const LEGACY_TOKEN_KEY = 'desktop_remote_token_v1'; +const SECURITY_UPGRADE_NOTICE_KEY = 'desktop_remote_security_upgrade_notice_v3'; + +export interface DesktopRemoteCredentials { + controlToken: string; + syncToken: string; + issuedAt: number; +} + +let legacyMigrationPromise: Promise | null = null; + +async function migrateLegacyPairing(): Promise { + legacyMigrationPromise ??= (async () => { + const [legacyConnection, legacyToken] = await Promise.all([ + SecureStore.getItemAsync(LEGACY_CONNECTION_KEY), + SecureStore.getItemAsync(LEGACY_TOKEN_KEY), + ]); + if (!legacyConnection && !legacyToken) return; + let label = 'Astra Desktop'; + try { + const parsed = legacyConnection ? JSON.parse(legacyConnection) as Record : null; + if (parsed && typeof parsed.desktopName === 'string' && parsed.desktopName.trim()) label = parsed.desktopName.trim(); + } catch { + // Only a non-secret label is retained. + } + await Promise.all([ + SecureStore.deleteItemAsync(LEGACY_CONNECTION_KEY), + SecureStore.deleteItemAsync(LEGACY_TOKEN_KEY), + SecureStore.setItemAsync(SECURITY_UPGRADE_NOTICE_KEY, label), + ]); + })(); + await legacyMigrationPromise; +} export async function getDesktopRemoteConnection(): Promise { + await migrateLegacyPairing(); const raw = await SecureStore.getItemAsync(CONNECTION_KEY); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; if (!parsed || typeof parsed !== 'object') return null; - if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null; + if ( + typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string' || + !parsed.baseUrl.startsWith('https://') || typeof parsed.certificateFingerprint !== 'string' || + parsed.protocolVersion !== 3 + ) return null; + const scopes = Array.isArray(parsed.scopes) + ? parsed.scopes.filter((scope): scope is 'control' | 'sync' => scope === 'control' || scope === 'sync') + : []; + if (!scopes.includes('control') || !scopes.includes('sync')) return null; return { id: parsed.id, baseUrl: parsed.baseUrl, + certificateFingerprint: parsed.certificateFingerprint, + scopes, + credentialIssuedAt: typeof parsed.credentialIssuedAt === 'number' ? parsed.credentialIssuedAt : parsed.pairedAt ?? Date.now(), + credentialRotatedAt: typeof parsed.credentialRotatedAt === 'number' ? parsed.credentialRotatedAt : parsed.pairedAt ?? Date.now(), + securityUpgradeState: 'none', endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null, desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null, - protocolVersion: - typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion) - ? parsed.protocolVersion - : 1, + protocolVersion: 3, deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null, pairedAt: typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt) @@ -39,18 +84,42 @@ export async function setDesktopRemoteConnection(connection: DesktopRemoteConnec await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection)); } -export async function getDesktopRemoteToken(): Promise { - const token = await SecureStore.getItemAsync(TOKEN_KEY); - return token && token.trim() ? token.trim() : null; +export async function getDesktopRemoteCredentials(): Promise { + const raw = await SecureStore.getItemAsync(CREDENTIALS_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed.controlToken?.trim() || !parsed.syncToken?.trim() || !Number.isFinite(parsed.issuedAt)) return null; + return { controlToken: parsed.controlToken.trim(), syncToken: parsed.syncToken.trim(), issuedAt: parsed.issuedAt! }; + } catch { + return null; + } } -export async function setDesktopRemoteToken(token: string): Promise { - await SecureStore.setItemAsync(TOKEN_KEY, token); +export async function setDesktopRemoteCredentials(credentials: DesktopRemoteCredentials): Promise { + await SecureStore.setItemAsync(CREDENTIALS_KEY, JSON.stringify(credentials)); +} + +export async function getDesktopRemoteToken(): Promise { + return (await getDesktopRemoteCredentials())?.controlToken ?? null; +} + +export async function getDesktopRemoteSyncToken(): Promise { + return (await getDesktopRemoteCredentials())?.syncToken ?? null; +} + +export async function getDesktopRemoteSecurityUpgradeNotice(): Promise { + await migrateLegacyPairing(); + return SecureStore.getItemAsync(SECURITY_UPGRADE_NOTICE_KEY); +} + +export async function clearDesktopRemoteSecurityUpgradeNotice(): Promise { + await SecureStore.deleteItemAsync(SECURITY_UPGRADE_NOTICE_KEY); } export async function clearDesktopRemotePairing(): Promise { await Promise.all([ SecureStore.deleteItemAsync(CONNECTION_KEY), - SecureStore.deleteItemAsync(TOKEN_KEY), + SecureStore.deleteItemAsync(CREDENTIALS_KEY), ]); } diff --git a/src/services/desktopRemotePairing.test.mts b/src/services/desktopRemotePairing.test.mts index c4ce29a..32eb729 100644 --- a/src/services/desktopRemotePairing.test.mts +++ b/src/services/desktopRemotePairing.test.mts @@ -6,13 +6,16 @@ import { parseDesktopRemotePairingInput, } from './desktopRemotePairing.ts'; -test('parses current PWA pairing URL format', () => { - assert.deepEqual( +const fingerprint = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99'; + +test('rejects browser and legacy HTTP pairing links on the native client', () => { + assert.equal( + parseDesktopRemotePairingInput('https://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'), + null + ); + assert.equal( parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'), - { - baseUrl: 'http://192.168.1.20:38402', - ticket: 'abcDEF_1234567890', - } + null ); }); @@ -21,21 +24,33 @@ test('parses native pairing links without accepting missing base URLs', () => { parseDesktopRemotePairingInput( 'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890' ), + null + ); + assert.deepEqual( + parseDesktopRemotePairingInput( + `astra://desktop-remote?baseUrl=https%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890&endpointUuid=endpoint-1&fingerprint=${encodeURIComponent(fingerprint)}&protocolVersion=3` + ), { - baseUrl: 'http://10.0.0.8:38402', + baseUrl: 'https://10.0.0.8:38402', ticket: 'abcDEF_1234567890', + endpointUuid: 'endpoint-1', + protocolVersion: 3, + certificateFingerprint: fingerprint, } ); assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null); }); -test('manual pairing requires a reachable http base URL and ticket-shaped code', () => { - assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), { - baseUrl: 'http://desktop.local:38402', +test('manual ticket parsing requires HTTPS, endpoint identity, and fingerprint', () => { + assert.deepEqual(parseDesktopRemoteManualInput('https://desktop.local:38402/remote/', 'abcDEF_1234567890', 'endpoint-1', fingerprint), { + baseUrl: 'https://desktop.local:38402', ticket: 'abcDEF_1234567890', + endpointUuid: 'endpoint-1', + protocolVersion: 3, + certificateFingerprint: fingerprint, }); - assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null); - assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null); + assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'abcDEF_1234567890', 'endpoint-1', fingerprint), null); + assert.equal(parseDesktopRemoteManualInput('https://desktop.local:38402', 'short', 'endpoint-1', fingerprint), null); }); test('PIN pairing accepts only six digits with optional spacing', () => { diff --git a/src/services/desktopRemotePairing.ts b/src/services/desktopRemotePairing.ts index 88369ed..9401696 100644 --- a/src/services/desktopRemotePairing.ts +++ b/src/services/desktopRemotePairing.ts @@ -8,13 +8,19 @@ function normalizeBaseUrl(value: string): string | null { if (!trimmed) return null; try { const parsed = new URL(trimmed); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.protocol !== 'https:') return null; return parsed.origin; } catch { return null; } } +function normalizeFingerprint(value: string): string | null { + const compact = value.trim().toUpperCase().replace(/[^0-9A-F]/g, ''); + if (!/^[0-9A-F]{64}$/.test(compact)) return null; + return compact.match(/.{2}/g)?.join(':') ?? null; +} + function extractPairFromUrl(url: URL): string { const hashParams = new URLSearchParams(url.hash.replace(/^#/, '')); const hashTicket = hashParams.get('pair')?.trim(); @@ -31,22 +37,46 @@ export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemoteP if (parsed.protocol === 'astra:') { const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? ''); const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? ''; - return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null; + const endpointUuid = parsed.searchParams.get('endpointUuid')?.trim() ?? ''; + const fingerprint = normalizeFingerprint(parsed.searchParams.get('fingerprint') ?? ''); + const protocolVersion = Number(parsed.searchParams.get('protocolVersion')); + return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket) + ? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint } + : null; } const ticket = extractPairFromUrl(parsed); const baseUrl = normalizeBaseUrl(parsed.origin); - return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null; + const hashParams = new URLSearchParams(parsed.hash.replace(/^#/, '')); + const endpointUuid = hashParams.get('endpointUuid')?.trim() ?? parsed.searchParams.get('endpointUuid')?.trim() ?? ''; + const fingerprint = normalizeFingerprint(hashParams.get('fingerprint') ?? parsed.searchParams.get('fingerprint') ?? ''); + const protocolVersion = Number(hashParams.get('protocolVersion') ?? parsed.searchParams.get('protocolVersion')); + return baseUrl && endpointUuid && fingerprint && protocolVersion === 3 && PAIRING_TICKET_PATTERN.test(ticket) + ? { baseUrl, ticket, endpointUuid, protocolVersion: 3, certificateFingerprint: fingerprint } + : null; } catch { - return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null; + return null; } } -export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null { +export function parseDesktopRemoteManualInput( + baseUrl: string, + ticket: string, + endpointUuid: string, + certificateFingerprint: string +): DesktopRemotePairingInput | null { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); const normalizedTicket = ticket.trim(); - if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null; - return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket }; + const normalizedEndpointUuid = endpointUuid.trim(); + const normalizedFingerprint = normalizeFingerprint(certificateFingerprint); + if (!normalizedBaseUrl || !normalizedEndpointUuid || !normalizedFingerprint || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null; + return { + baseUrl: normalizedBaseUrl, + ticket: normalizedTicket, + endpointUuid: normalizedEndpointUuid, + protocolVersion: 3, + certificateFingerprint: normalizedFingerprint, + }; } export function normalizeDesktopRemotePinInput(pin: string): string | null { diff --git a/src/services/desktopRemoteSession.ts b/src/services/desktopRemoteSession.ts new file mode 100644 index 0000000..030ac2f --- /dev/null +++ b/src/services/desktopRemoteSession.ts @@ -0,0 +1,58 @@ +import { + DesktopRemoteHttpError, + inspectDesktopRemoteSession, + rotateDesktopRemoteCredentials, +} from '@/services/desktopRemoteClient'; +import { + getDesktopRemoteCredentials, + setDesktopRemoteConnection, + setDesktopRemoteCredentials, + type DesktopRemoteCredentials, +} from '@/services/desktopRemoteCredentials'; +import type { DesktopRemoteConnection } from '@/types/desktopRemote'; + +export async function ensureDesktopRemoteCredentialsFresh( + connection: DesktopRemoteConnection, + suppliedCredentials?: DesktopRemoteCredentials +): Promise<{ connection: DesktopRemoteConnection; credentials: DesktopRemoteCredentials }> { + const credentials = suppliedCredentials ?? await getDesktopRemoteCredentials(); + if (!credentials) throw new Error('Desktop credentials are unavailable. Pair again.'); + let shouldRotate = false; + try { + const session = await inspectDesktopRemoteSession( + connection.baseUrl, + credentials.controlToken, + connection.certificateFingerprint + ); + shouldRotate = session.usingPreviousCredential || Date.now() >= session.rotateAfter; + } catch (error) { + if (!(error instanceof DesktopRemoteHttpError) || error.status !== 401) throw error; + shouldRotate = true; + } + if (!shouldRotate) return { connection, credentials }; + + const rotated = await rotateDesktopRemoteCredentials( + connection.baseUrl, + credentials.controlToken, + connection.certificateFingerprint + ); + if (!rotated.controlToken?.trim() || !rotated.syncToken?.trim()) { + throw new Error('Desktop returned incomplete rotated credentials.'); + } + const nextCredentials: DesktopRemoteCredentials = { + controlToken: rotated.controlToken, + syncToken: rotated.syncToken, + issuedAt: rotated.issuedAt, + }; + const nextConnection: DesktopRemoteConnection = { + ...connection, + credentialIssuedAt: rotated.issuedAt, + credentialRotatedAt: rotated.issuedAt, + lastConnectedAt: Date.now(), + }; + await Promise.all([ + setDesktopRemoteConnection(nextConnection), + setDesktopRemoteCredentials(nextCredentials), + ]); + return { connection: nextConnection, credentials: nextCredentials }; +} diff --git a/src/services/desktopSync.ts b/src/services/desktopSync.ts index 062a3c9..e4bc1e3 100644 --- a/src/services/desktopSync.ts +++ b/src/services/desktopSync.ts @@ -62,10 +62,11 @@ import { postDesktopSyncConflicts, } from './desktopRemoteClient'; import { + getDesktopRemoteCredentials, getDesktopRemoteConnection, - getDesktopRemoteToken, - setDesktopRemoteConnection, + getDesktopRemoteSyncToken, } from './desktopRemoteCredentials'; +import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession'; const CLOCK_SKEW_WARN_MS = 5 * 60_000; @@ -160,10 +161,10 @@ export async function runDesktopSync(): Promise { // Report remaining conflicts (best-effort — older desktops 404 here). const connection = await getDesktopRemoteConnection(); - const token = await getDesktopRemoteToken(); + const token = await getDesktopRemoteSyncToken(); if (connection && token) { try { - await postDesktopSyncConflicts(connection.baseUrl, token, { + await postDesktopSyncConflicts(connection.baseUrl, token, connection.certificateFingerprint, { syncFormat: DESKTOP_SYNC_FORMAT, conflicts: summary.conflicts.map((conflict) => ({ kind: conflict.kind, @@ -194,22 +195,22 @@ async function runDesktopSyncOnce(): Promise<{ pendingResolutions: DesktopSyncPendingResolution[]; }> { const startedAt = Date.now(); - const connection = await getDesktopRemoteConnection(); - const token = await getDesktopRemoteToken(); - if (!connection || !token) { + let connection = await getDesktopRemoteConnection(); + const credentials = await getDesktopRemoteCredentials(); + if (!connection || !credentials) { throw new Error('No paired desktop.'); } + const fresh = await ensureDesktopRemoteCredentialsFresh(connection, credentials); + connection = fresh.connection; + const token = fresh.credentials.syncToken; // The stored protocolVersion predates any desktop upgrade — re-check live and // persist the refreshed value before gating. - const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + const identity = await fetchDesktopRemoteIdentity(connection.baseUrl, connection.certificateFingerprint); if (!identity) { throw new Error('Desktop is unreachable.'); } - if (identity.protocolVersion !== connection.protocolVersion) { - await setDesktopRemoteConnection({ ...connection, protocolVersion: identity.protocolVersion }); - } - if (identity.protocolVersion < DESKTOP_SYNC_MIN_PROTOCOL_VERSION) { + if (identity.protocolVersion !== DESKTOP_SYNC_MIN_PROTOCOL_VERSION || identity.endpointUuid !== connection.endpointUuid) { throw new DesktopSyncUnsupportedError(); } @@ -218,7 +219,7 @@ async function runDesktopSyncOnce(): Promise<{ const index = buildImportIndex(await getAllTracks(db)); await resolvePendingFavorites(db, index); const local = await getLocalSyncState(db); - const remote = await fetchDesktopSyncState(connection.baseUrl, token); + const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint); if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) { throw new DesktopSyncUnsupportedError(); } @@ -556,7 +557,12 @@ async function runDesktopSyncOnce(): Promise<{ payload.playlistUpserts.length > 0 || payload.playlistDeletes.length > 0; if (hasDiff) { - const result = await postDesktopSyncApply(connection.baseUrl, token, payload); + const result = await postDesktopSyncApply( + connection.baseUrl, + token, + connection.certificateFingerprint, + payload + ); summary.pushedToDesktop = true; summary.favoritesAdded += result.favorites.added; summary.favoritesPending += result.favorites.pending; diff --git a/src/services/desktopSyncPolicy.test.mts b/src/services/desktopSyncPolicy.test.mts new file mode 100644 index 0000000..eea1462 --- /dev/null +++ b/src/services/desktopSyncPolicy.test.mts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + canStartDesktopSync, + decideDesktopSyncEnabled, + identityMatchesPinnedConnection, +} from './desktopSyncPolicy.ts'; + +test('legacy automatic-sync migration preserves explicit off and defaults to on', () => { + assert.equal(decideDesktopSyncEnabled(null, '0'), false); + assert.equal(decideDesktopSyncEnabled(null, '1'), true); + assert.equal(decideDesktopSyncEnabled(null, null), true); + assert.equal(decideDesktopSyncEnabled('0', '1'), false); +}); + +test('master switch gates manual, automatic, and follow-up starts', () => { + assert.equal(canStartDesktopSync(false, 'idle'), false); + assert.equal(canStartDesktopSync(false, 'error'), false); + assert.equal(canStartDesktopSync(true, 'syncing'), false); + assert.equal(canStartDesktopSync(true, 'idle'), true); +}); + +test('discovered address requires v3 identity and the paired endpoint UUID', () => { + assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'endpoint-1'), true); + assert.equal(identityMatchesPinnedConnection('endpoint-1', 3, 'impostor'), false); + assert.equal(identityMatchesPinnedConnection('endpoint-1', 2, 'endpoint-1'), false); +}); diff --git a/src/services/desktopSyncPolicy.ts b/src/services/desktopSyncPolicy.ts new file mode 100644 index 0000000..abcf3a6 --- /dev/null +++ b/src/services/desktopSyncPolicy.ts @@ -0,0 +1,23 @@ +export function decideDesktopSyncEnabled( + masterSetting: string | null, + legacyAutoSetting: string | null +): boolean { + if (masterSetting !== null) return masterSetting !== '0'; + // Conservative migration: an explicit legacy 0 stays off; 1 or absence is on. + return legacyAutoSetting !== '0'; +} + +export function canStartDesktopSync( + enabled: boolean, + status: 'idle' | 'syncing' | 'error' +): boolean { + return enabled && status !== 'syncing'; +} + +export function identityMatchesPinnedConnection( + expectedEndpointUuid: string | null, + protocolVersion: number, + observedEndpointUuid: string | null +): boolean { + return protocolVersion === 3 && Boolean(expectedEndpointUuid) && observedEndpointUuid === expectedEndpointUuid; +} diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts index 9da18ed..6f0ed32 100644 --- a/src/stores/desktopRemoteStore.ts +++ b/src/stores/desktopRemoteStore.ts @@ -12,7 +12,6 @@ import { fetchDesktopRemoteNowPlaying, fetchDesktopRemotePairingStatus, fetchDesktopRemoteQueue, - parseDesktopRemoteManualInput, parseDesktopRemotePairingInput, requestDesktopRemotePinPairing, sendDesktopRemoteControl, @@ -22,14 +21,18 @@ import { import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing'; import { clearDesktopRemotePairing, + clearDesktopRemoteSecurityUpgradeNotice, + getDesktopRemoteCredentials, getDesktopRemoteConnection, - getDesktopRemoteToken, + getDesktopRemoteSecurityUpgradeNotice, setDesktopRemoteConnection, - setDesktopRemoteToken, + setDesktopRemoteCredentials, } from '@/services/desktopRemoteCredentials'; import { openLibraryDb } from '@/db/database'; import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; +import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; +import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import type { DesktopRemoteConnection, @@ -58,11 +61,14 @@ interface PairingAttempt { baseUrl: string; pollToken: string; expiresAt: number; + certificateFingerprint: string; + endpointUuid: string; } interface PinPairingAttempt { baseUrl: string; requestId: string; + attemptId: string; expiresAt: number; desktopName: string | null; } @@ -89,7 +95,7 @@ interface DesktopRemoteStore { requestPinPairing: (baseUrl: string) => Promise; confirmPinPairing: (pin: string) => Promise; pairFromInput: (input: string) => Promise; - pairManual: (baseUrl: string, ticket: string) => Promise; + pairManual: (baseUrl: string) => Promise; connect: () => Promise; reconnect: () => Promise; disconnect: () => void; @@ -148,6 +154,13 @@ function errorMessage(error: unknown): string { return 'Desktop remote request failed.'; } +function isDesktopCertificateError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + return message.includes('certificate changed') || + message.includes('certificate mismatch') || + message.includes('pinning failure'); +} + function mergeSnapshotArtwork( previous: DesktopRemoteNowPlayingSnapshot | null, next: DesktopRemoteNowPlayingSnapshot @@ -168,25 +181,39 @@ function mergeSnapshotArtwork( async function persistConnectedDesktop( baseUrl: string, - token: string, + credentials: { controlToken: string; syncToken: string; issuedAt: number }, + certificateFingerprint: string, deviceId: string | null, - identity: DesktopRemoteIdentity | null + identity: DesktopRemoteIdentity | null, + expectedEndpointUuid?: string ): Promise { - const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl)); + const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl, certificateFingerprint)); + if (!resolvedIdentity || resolvedIdentity.protocolVersion !== 3) { + throw new Error('Desktop does not support secure protocol v3.'); + } + if (expectedEndpointUuid && resolvedIdentity.endpointUuid !== expectedEndpointUuid) { + throw new Error('Desktop identity does not match the pairing QR.'); + } const now = Date.now(); const connection: DesktopRemoteConnection = { id: stableConnectionId(resolvedIdentity, baseUrl), baseUrl, + certificateFingerprint, + scopes: ['control', 'sync'], + credentialIssuedAt: credentials.issuedAt, + credentialRotatedAt: credentials.issuedAt, + securityUpgradeState: 'none', endpointUuid: resolvedIdentity?.endpointUuid ?? null, desktopName: displayName(resolvedIdentity, baseUrl), - protocolVersion: resolvedIdentity?.protocolVersion ?? 1, + protocolVersion: 3, deviceId, pairedAt: now, lastConnectedAt: now, }; await Promise.all([ setDesktopRemoteConnection(connection), - setDesktopRemoteToken(token), + setDesktopRemoteCredentials(credentials), + clearDesktopRemoteSecurityUpgradeNotice(), ]); return connection; } @@ -199,7 +226,7 @@ export const useDesktopRemoteStore = create((set, get) => { const requestKey = `${connection.id}:${track.id}`; if (inlineArtworkRequestKey === requestKey) return; inlineArtworkRequestKey = requestKey; - void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true).then( + void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint, true).then( (inlineSnapshot) => { inlineArtworkRequestKey = null; set((state) => ({ @@ -219,7 +246,7 @@ export const useDesktopRemoteStore = create((set, get) => { snapshotPollTimer = setInterval(() => { const { connection, token, connectionState } = get(); if (!connection || !token || connectionState === 'connecting') return; - void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then( + void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, connection.certificateFingerprint).then( (snapshot) => { const wasConnected = get().connectionState === 'connected'; set((state) => ({ @@ -233,6 +260,10 @@ export const useDesktopRemoteStore = create((set, get) => { refreshInlineArtwork(); }, (error) => { + if (isDesktopCertificateError(error)) { + void get().forget().then(() => set({ errorMessage: 'Desktop certificate changed—pair again.' })); + return; + } if (error instanceof DesktopRemoteHttpError && error.status === 401) { void get().forget(); set({ errorMessage: 'Desktop pairing was revoked.' }); @@ -262,18 +293,26 @@ export const useDesktopRemoteStore = create((set, get) => { const pairing = get().pairing; if (!pairing) return; try { - const status = await fetchDesktopRemotePairingStatus(pairing.baseUrl, pairing.pollToken); - if (status.state === 'approved' && status.token?.trim()) { + const status = await fetchDesktopRemotePairingStatus( + pairing.baseUrl, + pairing.pollToken, + pairing.certificateFingerprint + ); + const controlToken = status.controlToken?.trim() || status.token?.trim(); + const syncToken = status.syncToken?.trim(); + if (status.state === 'approved' && controlToken && syncToken) { clearPairingPoll(); const connection = await persistConnectedDesktop( pairing.baseUrl, - status.token.trim(), + { controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() }, + pairing.certificateFingerprint, status.deviceId ?? null, - status.identity ?? null + status.identity ?? null, + pairing.endpointUuid ); set({ connection, - token: status.token.trim(), + token: controlToken, pairing: null, pinPairing: null, connectionState: 'connecting', @@ -321,7 +360,12 @@ export const useDesktopRemoteStore = create((set, get) => { } }; - const claimPairing = async (baseUrl: string, ticket: string) => { + const claimPairing = async ( + baseUrl: string, + ticket: string, + certificateFingerprint: string, + endpointUuid: string + ) => { clearPairingPoll(); stopRealtime(); set({ @@ -336,6 +380,7 @@ export const useDesktopRemoteStore = create((set, get) => { const claim = await claimDesktopRemotePairingTicket( baseUrl, ticket, + certificateFingerprint, defaultDesktopRemoteDeviceName() ); if (!claim.pollToken) throw new Error('Desktop did not return a pairing poll token.'); @@ -345,6 +390,8 @@ export const useDesktopRemoteStore = create((set, get) => { baseUrl, pollToken: claim.pollToken, expiresAt: claim.expiresAt, + certificateFingerprint, + endpointUuid, }, message: 'Approve this phone in Astra on desktop.', }); @@ -380,6 +427,7 @@ export const useDesktopRemoteStore = create((set, get) => { pinPairing: { baseUrl, requestId: request.requestId, + attemptId: request.attemptId, expiresAt: request.expiresAt, desktopName, }, @@ -406,19 +454,22 @@ export const useDesktopRemoteStore = create((set, get) => { } set({ connectionState: 'pairing', message: 'Confirming PIN...', errorMessage: '' }); try { - const status = await confirmDesktopRemotePinPairing(attempt.baseUrl, attempt.requestId, normalizedPin); - if (status.state !== 'approved' || !status.token?.trim()) { + const status = await confirmDesktopRemotePinPairing(attempt.attemptId, normalizedPin); + const controlToken = status.controlToken?.trim() || status.token?.trim(); + const syncToken = status.syncToken?.trim(); + if (status.state !== 'approved' || !controlToken || !syncToken || !status.certificateFingerprint) { throw new Error('Desktop did not approve this PIN pairing.'); } const connection = await persistConnectedDesktop( attempt.baseUrl, - status.token.trim(), + { controlToken, syncToken, issuedAt: status.issuedAt ?? Date.now() }, + status.certificateFingerprint, status.deviceId ?? null, status.identity ?? null ); set({ connection, - token: status.token.trim(), + token: controlToken, pairing: null, pinPairing: null, connectionState: 'connecting', @@ -428,7 +479,10 @@ export const useDesktopRemoteStore = create((set, get) => { void usePlaybackTargetStore.getState().setTarget('desktop'); void get().connect(); } catch (error) { - if (error instanceof DesktopRemoteHttpError && error.status === 401) { + if ( + (error instanceof DesktopRemoteHttpError && error.status === 401) || + errorMessage(error).toLowerCase().includes('wrong pin') + ) { set({ connectionState: 'pinEntry', message: `Enter the PIN shown on ${attempt.desktopName || 'Astra Desktop'}.`, @@ -462,15 +516,20 @@ export const useDesktopRemoteStore = create((set, get) => { init: async () => { if (get().initialized) return; - const [connection, token] = await Promise.all([ + const [connection, credentials, securityUpgradeLabel] = await Promise.all([ getDesktopRemoteConnection(), - getDesktopRemoteToken(), + getDesktopRemoteCredentials(), + getDesktopRemoteSecurityUpgradeNotice(), ]); + const token = credentials?.controlToken ?? null; set({ initialized: true, connection, token, connectionState: connection && token ? 'connecting' : 'unpaired', + errorMessage: securityUpgradeLabel + ? `Security upgrade required—pair again. Previously paired: ${securityUpgradeLabel}.` + : '', }); if (connection && token) void get().connect(); }, @@ -489,6 +548,22 @@ export const useDesktopRemoteStore = create((set, get) => { ), }; }); + const connection = get().connection; + if ( + connection?.endpointUuid && desktop.endpointUuid === connection.endpointUuid && + desktop.baseUrl !== connection.baseUrl + ) { + void fetchDesktopRemoteIdentity(desktop.baseUrl, connection.certificateFingerprint).then(async (identity) => { + if (!identity || !identityMatchesPinnedConnection( + connection.endpointUuid, + identity.protocolVersion, + identity.endpointUuid + )) return; + const nextConnection = { ...connection, baseUrl: desktop.baseUrl }; + await setDesktopRemoteConnection(nextConnection); + set({ connection: nextConnection }); + }); + } }), AstraDesktopDiscovery.addListener('onDesktopRemoteLost', (event) => { set((state) => ({ @@ -520,23 +595,27 @@ export const useDesktopRemoteStore = create((set, get) => { }); return; } - await claimPairing(parsed.baseUrl, parsed.ticket); + await claimPairing( + parsed.baseUrl, + parsed.ticket, + parsed.certificateFingerprint, + parsed.endpointUuid + ); }, - pairManual: async (baseUrl: string, ticket: string) => { - const parsed = parseDesktopRemoteManualInput(baseUrl, ticket); - if (!parsed) { + pairManual: async (baseUrl: string) => { + if (!baseUrl.trim().toLowerCase().startsWith('https://')) { set({ connectionState: 'error', - errorMessage: 'Enter a valid desktop URL and pairing code.', + errorMessage: 'Enter the HTTPS desktop URL shown by Astra.', }); return; } - await claimPairing(parsed.baseUrl, parsed.ticket); + await requestPinPairing(baseUrl.trim().replace(/\/+$/, '')); }, connect: async () => { - const { connection, token } = get(); + let { connection, token } = get(); if (!connection || !token) { set({ connectionState: 'unpaired' }); return false; @@ -544,18 +623,33 @@ export const useDesktopRemoteStore = create((set, get) => { stopRealtime(); set({ connectionState: 'connecting', message: 'Connecting to desktop...', errorMessage: '' }); try { - const snapshot = await fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true); + const storedCredentials = await getDesktopRemoteCredentials(); + if (!storedCredentials) throw new Error('Desktop credentials are unavailable. Pair again.'); + const fresh = await ensureDesktopRemoteCredentialsFresh(connection, storedCredentials); + connection = fresh.connection; + token = fresh.credentials.controlToken; + const snapshot = await fetchDesktopRemoteNowPlaying( + connection.baseUrl, + token, + connection.certificateFingerprint, + true + ); const nextConnection = { ...connection, lastConnectedAt: Date.now() }; await setDesktopRemoteConnection(nextConnection); set({ connection: nextConnection, + token, snapshot, connectionState: 'connected', message: '', errorMessage: '', }); useDesktopSyncStore.getState().maybeAutoSync('connected'); - stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, { + stopEventStream = startDesktopRemoteEventStream( + connection.baseUrl, + token, + connection.certificateFingerprint, + { onSnapshot: (nextSnapshot) => { const wasConnected = get().connectionState === 'connected'; set((state) => ({ @@ -583,11 +677,17 @@ export const useDesktopRemoteStore = create((set, get) => { onError: () => { scheduleSnapshotPoll(); }, - }); + } + ); scheduleSnapshotPoll(); void get().refreshQueue(); return true; } catch (error) { + if (isDesktopCertificateError(error)) { + await get().forget(); + set({ errorMessage: 'Desktop certificate changed—pair again.' }); + return false; + } if (error instanceof DesktopRemoteHttpError && error.status === 401) { await get().forget(); set({ errorMessage: 'Desktop pairing was revoked.' }); @@ -638,7 +738,13 @@ export const useDesktopRemoteStore = create((set, get) => { const { connection, token } = get(); if (!connection || !token) return; try { - await sendDesktopRemoteControl(connection.baseUrl, token, command, time); + await sendDesktopRemoteControl( + connection.baseUrl, + token, + connection.certificateFingerprint, + command, + time + ); set({ errorMessage: '' }); if (command === 'seek' && typeof time === 'number') { set((state) => state.snapshot @@ -646,6 +752,11 @@ export const useDesktopRemoteStore = create((set, get) => { : {}); } } catch (error) { + if (isDesktopCertificateError(error)) { + await get().forget(); + set({ errorMessage: 'Desktop certificate changed—pair again.' }); + return; + } if (error instanceof DesktopRemoteHttpError && error.status === 401) { await get().forget(); set({ errorMessage: 'Desktop pairing was revoked.' }); @@ -659,9 +770,17 @@ export const useDesktopRemoteStore = create((set, get) => { const { connection, token } = get(); if (!connection || !token) return; try { - const queue = await fetchDesktopRemoteQueue(connection.baseUrl, token); + const queue = await fetchDesktopRemoteQueue( + connection.baseUrl, + token, + connection.certificateFingerprint + ); set({ queue }); - } catch { + } catch (error) { + if (isDesktopCertificateError(error)) { + await get().forget(); + set({ errorMessage: 'Desktop certificate changed—pair again.' }); + } // Protocol-1 desktops 404 here; the queue UI simply stays hidden. } }, @@ -670,9 +789,19 @@ export const useDesktopRemoteStore = create((set, get) => { const { connection, token } = get(); if (!connection || !token) return; try { - await sendDesktopRemotePlayQueueItem(connection.baseUrl, token, queueId); + await sendDesktopRemotePlayQueueItem( + connection.baseUrl, + token, + connection.certificateFingerprint, + queueId + ); set({ errorMessage: '' }); } catch (error) { + if (isDesktopCertificateError(error)) { + await get().forget(); + set({ errorMessage: 'Desktop certificate changed—pair again.' }); + return; + } if (error instanceof DesktopRemoteHttpError && error.status === 401) { await get().forget(); set({ errorMessage: 'Desktop pairing was revoked.' }); diff --git a/src/stores/desktopSyncStore.ts b/src/stores/desktopSyncStore.ts index e686f19..81313d9 100644 --- a/src/stores/desktopSyncStore.ts +++ b/src/stores/desktopSyncStore.ts @@ -15,6 +15,7 @@ import { } from '@/services/desktopSync'; import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials'; import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient'; +import { canStartDesktopSync, decideDesktopSyncEnabled } from '@/services/desktopSyncPolicy'; import type { DesktopSyncConflictResolution, DesktopSyncPlaylistConflict, @@ -23,13 +24,15 @@ import type { const AUTO_SYNC_DEBOUNCE_MS = 5_000; const AUTO_SYNC_MIN_INTERVAL_MS = 15 * 60_000; -const AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto'; +const DESKTOP_SYNC_ENABLED_SETTING_KEY = 'desktop_sync_enabled_v1'; +const LEGACY_AUTO_SYNC_SETTING_KEY = 'desktop_sync_auto'; export type DesktopSyncStatus = 'idle' | 'syncing' | 'error'; export type DesktopSyncAutoReason = 'discovery' | 'connected' | 'foreground'; interface DesktopSyncStore { + hydrated: boolean; status: DesktopSyncStatus; /** Wall-clock ms of the last successful sync with the paired desktop. */ lastSyncAt: number | null; @@ -42,14 +45,13 @@ interface DesktopSyncStore { errorMessage: string; /** False once the paired desktop reported a pre-sync protocol version. */ supported: boolean; - /** Automatic syncing (foreground/discovery). Manual + desktop-requested - * syncs run regardless. */ - autoSyncEnabled: boolean; + /** Master gate for every desktop library-sync trigger. Remote playback is independent. */ + desktopSyncEnabled: boolean; hydrate: () => Promise; syncNow: () => Promise; dismissConflictPrompt: () => void; - setAutoSyncEnabled: (enabled: boolean) => Promise; + setDesktopSyncEnabled: (enabled: boolean) => Promise; resolveConflict: ( conflict: DesktopSyncPlaylistConflict, resolution: DesktopSyncConflictResolution @@ -66,6 +68,7 @@ let autoSyncDebounceTimer: ReturnType | null = null; const promptedConflictUids = new Set(); export const useDesktopSyncStore = create((set, get) => ({ + hydrated: false, status: 'idle', lastSyncAt: null, lastSummary: null, @@ -73,39 +76,49 @@ export const useDesktopSyncStore = create((set, get) => ({ conflictPromptVisible: false, errorMessage: '', supported: true, - autoSyncEnabled: true, + desktopSyncEnabled: false, hydrate: async () => { try { const db = await openLibraryDb(); - const autoSetting = await getSetting(db, AUTO_SYNC_SETTING_KEY); - if (autoSetting !== null) { - set({ autoSyncEnabled: autoSetting !== '0' }); + const masterSetting = await getSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY); + const legacySetting = await getSetting(db, LEGACY_AUTO_SYNC_SETTING_KEY); + const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting); + set({ desktopSyncEnabled: enabled }); + if (masterSetting === null) { + await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } const connection = await getDesktopRemoteConnection(); - if (!connection) return; - const stored = await getSetting(db, desktopSyncSettingKey(connection)); - const lastSyncAt = stored ? Number(stored) : NaN; - if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) { - set({ lastSyncAt }); + if (connection) { + const stored = await getSetting(db, desktopSyncSettingKey(connection)); + const lastSyncAt = stored ? Number(stored) : NaN; + if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) { + set({ lastSyncAt }); + } } } catch { // Hydration is best-effort; the first sync will set lastSyncAt. + } finally { + set({ hydrated: true }); } }, - setAutoSyncEnabled: async (enabled) => { - set({ autoSyncEnabled: enabled }); + setDesktopSyncEnabled: async (enabled) => { + if (!enabled && autoSyncDebounceTimer !== null) { + clearTimeout(autoSyncDebounceTimer); + autoSyncDebounceTimer = null; + } + set({ desktopSyncEnabled: enabled }); try { const db = await openLibraryDb(); - await setSetting(db, AUTO_SYNC_SETTING_KEY, enabled ? '1' : '0'); + await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } catch { // The in-memory value still applies for this session. } }, syncNow: async () => { - if (get().status === 'syncing') return; + if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return; set({ status: 'syncing', errorMessage: '' }); try { const summary = await runDesktopSync(); @@ -138,7 +151,7 @@ export const useDesktopSyncStore = create((set, get) => ({ }, resolveConflict: async (conflict, resolution) => { - if (get().status === 'syncing') return; + if (!canStartDesktopSync(get().desktopSyncEnabled, get().status)) return; try { await applyDesktopSyncConflictResolution(conflict, resolution); } catch (error) { @@ -163,14 +176,14 @@ export const useDesktopSyncStore = create((set, get) => ({ }, handleSyncRequest: () => { - const { status } = get(); - if (status === 'syncing') return; + const { status, desktopSyncEnabled } = get(); + if (!canStartDesktopSync(desktopSyncEnabled, status)) return; void get().syncNow(); }, maybeAutoSync: (reason) => { - const { status, lastSyncAt, supported, autoSyncEnabled } = get(); - if (!supported || !autoSyncEnabled) return; + const { status, lastSyncAt, supported, desktopSyncEnabled } = get(); + if (!supported || !desktopSyncEnabled) return; if (status === 'syncing') return; if (AppState.currentState !== 'active') return; if (lastSyncAt !== null && Date.now() - lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; @@ -181,6 +194,7 @@ export const useDesktopSyncStore = create((set, get) => ({ autoSyncDebounceTimer = null; void (async () => { const state = useDesktopSyncStore.getState(); + if (!state.desktopSyncEnabled) return; if (state.status === 'syncing') return; if (AppState.currentState !== 'active') return; if (state.lastSyncAt !== null && Date.now() - state.lastSyncAt < AUTO_SYNC_MIN_INTERVAL_MS) return; @@ -189,7 +203,10 @@ export const useDesktopSyncStore = create((set, get) => ({ // user never asked for. const connection = await getDesktopRemoteConnection(); if (!connection) return; - const identity = await fetchDesktopRemoteIdentity(connection.baseUrl); + const identity = await fetchDesktopRemoteIdentity( + connection.baseUrl, + connection.certificateFingerprint + ); if (!identity) return; void state.syncNow(); })(); diff --git a/src/types/desktopRemote.ts b/src/types/desktopRemote.ts index 0914ee9..b453c09 100644 --- a/src/types/desktopRemote.ts +++ b/src/types/desktopRemote.ts @@ -1,4 +1,6 @@ -export const DESKTOP_REMOTE_PROTOCOL_VERSION = 2; +export const DESKTOP_REMOTE_PROTOCOL_VERSION = 3; +export type DesktopRemoteCredentialScope = 'control' | 'sync'; +export type DesktopRemoteSecurityUpgradeState = 'none' | 'repair-required'; export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading'; export type DesktopRemoteRepeatMode = 'none' | 'one' | 'all'; @@ -22,8 +24,14 @@ export interface DesktopRemoteIdentity { } export interface DesktopRemoteConnection extends DesktopRemoteIdentity { + protocolVersion: 3; id: string; baseUrl: string; + certificateFingerprint: string; + scopes: DesktopRemoteCredentialScope[]; + credentialIssuedAt: number; + credentialRotatedAt: number; + securityUpgradeState: DesktopRemoteSecurityUpgradeState; deviceId: string | null; pairedAt: number; lastConnectedAt: number | null; @@ -77,7 +85,11 @@ export interface DesktopRemotePairingClaim { identity: DesktopRemoteIdentity | null; } -export type DesktopRemotePinPairingRequest = DesktopRemotePairingClaim; +export interface DesktopRemotePinPairingRequest extends DesktopRemotePairingClaim { + attemptId: string; + certificateFingerprint: string; + protocolVersion: 3; +} export type DesktopRemotePairingState = | 'pending' @@ -90,6 +102,11 @@ export interface DesktopRemotePairingStatus { state: DesktopRemotePairingState; expiresAt: number; token?: string; + controlToken?: string; + syncToken?: string; + issuedAt?: number; + scopes?: DesktopRemoteCredentialScope[]; + certificateFingerprint?: string; deviceId?: string | null; identity?: DesktopRemoteIdentity | null; } @@ -97,11 +114,16 @@ export interface DesktopRemotePairingStatus { export interface DesktopRemotePairingInput { baseUrl: string; ticket: string; + endpointUuid: string; + protocolVersion: 3; + certificateFingerprint: string; } export interface DesktopRemoteDiscoveredDesktop extends DesktopRemoteIdentity { name: string; baseUrl: string; + certificateFingerprint: string; + transport: 'https'; address: string; port: number; lastSeenAt: number; diff --git a/src/types/desktopSync.ts b/src/types/desktopSync.ts index 72d2c0d..848fcc8 100644 --- a/src/types/desktopSync.ts +++ b/src/types/desktopSync.ts @@ -9,7 +9,7 @@ export const DESKTOP_SYNC_FORMAT = 1; /** Desktop protocol version that introduced /v1/sync/* (and queue/shuffle). */ -export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 2; +export const DESKTOP_SYNC_MIN_PROTOCOL_VERSION = 3; export interface SyncFavorite { key: string;