mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
improve mobile sync security
This commit is contained in:
+6
-1
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+464
@@ -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<out X509Certificate>?, authType: String?) = Unit
|
||||
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, 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<X509Certificate> = emptyArray()
|
||||
}
|
||||
|
||||
internal class ObservingTrustManager : X509TrustManager {
|
||||
@Volatile var observedFingerprint: String? = null
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
val leaf = chain?.firstOrNull() ?: throw java.security.cert.CertificateException("Desktop sent no certificate.")
|
||||
observedFingerprint = certificateFingerprint(leaf)
|
||||
}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = 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>(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<Int, String> {
|
||||
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<String, Job>()
|
||||
private val pairingAttempts = ConcurrentHashMap<String, PairingAttempt>()
|
||||
|
||||
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<String, Any?> {
|
||||
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<String, Any?> {
|
||||
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<String>()
|
||||
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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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<String>(), fixture.authorizationHeaders)
|
||||
assertEquals(emptyList<String>(), 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<String>,
|
||||
val requestBodies: CopyOnWriteArrayList<String>,
|
||||
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<String>()
|
||||
val bodies = CopyOnWriteArrayList<String>()
|
||||
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<String, String>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.astradesktoptransport.AstraDesktopTransportModule"]
|
||||
}
|
||||
}
|
||||
@@ -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<AstraDesktopTransportEvents> {
|
||||
requestJson(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body: string | null,
|
||||
token: string | null,
|
||||
certificateFingerprint: string,
|
||||
timeoutMs: number
|
||||
): Promise<AstraDesktopTransportResponse>;
|
||||
startEventStream(baseUrl: string, token: string, certificateFingerprint: string): Promise<string>;
|
||||
stopEventStream(streamId: string): void;
|
||||
beginPinPairing(baseUrl: string, deviceName: string, clientLabel: string): Promise<AstraDesktopPinAttempt>;
|
||||
confirmPinPairing(attemptId: string, enteredCode: string): Promise<AstraDesktopPinResult>;
|
||||
}
|
||||
|
||||
export const AstraDesktopTransport =
|
||||
requireOptionalNativeModule<AstraDesktopTransportModuleType>('AstraDesktopTransport');
|
||||
|
||||
export const desktopPinnedTransportAvailable = AstraDesktopTransport != null;
|
||||
Reference in New Issue
Block a user