websockets

This commit is contained in:
koalasat
2025-07-17 12:41:54 +02:00
parent ef08cf2427
commit 6bef6dfb1f
7 changed files with 296 additions and 104 deletions

View File

@ -11,9 +11,15 @@ interface AndroidAppRobosats {
generateRobohash: (uuid: string, initialString: string) => void;
copyToClipboard: (value: string) => void;
getTorStatus: (uuid: string) => void;
openWS: (uuid: string, path: string) => void;
sendWsMessage: (uuid: string, path: string, message: string) => void;
}
class AndroidRobosats {
private WSConnections: Record<string, (message?: string) => void> = {};
private WSError: Record<string, () => void> = {};
private WSClose: Record<string, () => void> = {};
private promises: Record<
string,
{
@ -51,6 +57,35 @@ class AndroidRobosats {
console.warn(`No promise found for UUID: ${uuid}`);
}
};
public registerWSConnection: (
path: string,
onMesage: (message?: string) => void,
onClose: () => void,
onError: () => void,
) => void = (path, onMesage, onClose, onError) => {
this.WSConnections[path] = onMesage;
this.WSError[path] = onError;
this.WSClose[path] = onClose;
};
public removeWSConnection: (path: string) => void = (path) => {
delete this.WSConnections[path];
delete this.WSError[path];
delete this.WSClose[path];
};
public onWSMessage: (path: string, message: string) => void = (path, message) => {
this.WSConnections[path](message);
};
public onWsError: (path: string) => void = (path) => {
this.WSError[path]();
};
public onWsClose: (path: string) => void = (path) => {
this.WSClose[path]();
};
}
export default AndroidRobosats;

View File

@ -0,0 +1,73 @@
import { WebsocketState, type WebsocketClient, type WebsocketConnection } from '..';
import WebsocketWebClient from '../WebsocketWebClient';
import { v4 as uuidv4 } from 'uuid';
class WebsocketConnectionAndroid implements WebsocketConnection {
constructor(path: string) {
this.path = path;
window.AndroidRobosats?.registerWSConnection(
path,
(message) => {
this.wsMessagePromises.forEach((f) => f({ data: message }));
},
() => {
this.wsClosePromises.forEach((f) => f());
},
() => {},
);
}
private readonly path: string;
private readonly wsMessagePromises: Array<(message: object) => void> = [];
private readonly wsClosePromises: Array<() => void> = [];
public send: (message: string) => void = (message: string) => {
const uuid: string = uuidv4();
window.AndroidAppRobosats?.sendWsMessage(uuid, this.path, message);
};
public close: () => void = () => {
window.AndroidRobosats?.removeWSConnection(this.path);
};
public onMessage: (event: (message: object) => void) => void = (event) => {
this.wsMessagePromises.push(event);
};
public onClose: (event: () => void) => void = (event) => {
this.wsClosePromises.push(event);
};
public onError: (event: (error: object) => void) => void = (_event) => {
// Not implemented
};
public getReadyState: () => number = () => WebsocketState.OPEN;
}
class WebsocketAndroidClient implements WebsocketClient {
public useProxy = true;
private readonly webClient: WebsocketWebClient = new WebsocketWebClient();
public open: (path: string) => Promise<WebsocketConnection> = async (path) => {
if (!this.useProxy) return await this.webClient.open(path);
return new Promise<WebsocketConnectionAndroid>((resolve, reject) => {
const uuid: string = uuidv4();
window.AndroidAppRobosats?.openWS(uuid, path);
window.AndroidRobosats?.storePromise(
uuid,
() => {
resolve(new WebsocketConnectionAndroid(path));
},
() => {
reject(new Error('Failed to establish a websocket connection.'));
},
);
});
};
}
export default WebsocketAndroidClient;

View File

@ -1,3 +1,4 @@
import WebsocketAndroidClient from './WebsocketAndroidClient';
import WebsocketNativeClient from './WebsocketNativeClient';
import WebsocketWebClient from './WebsocketWebClient';
@ -23,7 +24,11 @@ export interface WebsocketClient {
}
function getWebsocketClient(): WebsocketClient {
if (window.navigator.userAgent.includes('robosats')) {
if (window.navigator.userAgent.includes('AndroidRobosats')) {
// If userAgent has "AndroidRobosats", we assume the app is running inside of the
// WebView of the Kotlin RoboSats Android app.
return new WebsocketAndroidClient();
} else if (window.navigator.userAgent.includes('robosats')) {
// If userAgent has "RoboSats", we assume the app is running inside of the
// react-native-web view of the RoboSats Android app.
return new WebsocketNativeClient();

View File

@ -70,6 +70,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.okhttp)
implementation(libs.kmp.tor)
// Add the KMP Tor binary dependency (contains the native .so files)
implementation(libs.kmp.tor.binary)

View File

@ -203,7 +203,7 @@ class MainActivity : AppCompatActivity() {
val proxyPort = System.getProperty("http.proxyPort")?.toIntOrNull()
?: throw SecurityException("Missing or invalid proxy port in system properties")
Log.d("TorProxy", "Using proxy settings: $proxyHost:$proxyPort")
Log.d("WebViewProxy", "Using proxy settings: $proxyHost:$proxyPort")
// Success - now configure WebViewClient and load URL on UI thread
runOnUiThread {
@ -243,7 +243,7 @@ class MainActivity : AppCompatActivity() {
}
val urlString = request.url.toString()
Log.d("TorProxy", "Intercepting request: $urlString")
Log.d("WebViewProxy", "Intercepting request: $urlString")
// Block all external requests that aren't to .onion domains or local files
if (!isAllowedRequest(urlString)) {
@ -274,7 +274,7 @@ class MainActivity : AppCompatActivity() {
)
if (isOnionDomain) {
Log.d("TorProxy", "Handling .onion domain with SOCKS proxy: $urlString")
Log.d("WebViewProxy", "Handling .onion domain with SOCKS proxy: $urlString")
}
// If it's a local file, return it directly
@ -345,7 +345,7 @@ class MainActivity : AppCompatActivity() {
val mimeType = connection.contentType ?: "text/plain"
val encoding = connection.contentEncoding ?: "UTF-8"
Log.d("TorProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})")
Log.d("WebViewProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})")
// Get the correct input stream based on response code
val inputStream = if (responseCode >= 400) {
@ -409,7 +409,7 @@ class MainActivity : AppCompatActivity() {
} else {
// For non-HTTP connections (rare)
val inputStream = connection.getInputStream()
Log.d("TorProxy", "Successfully established non-HTTP connection to $url")
Log.d("WebViewProxy", "Successfully established non-HTTP connection to $url")
return WebResourceResponse(
"application/octet-stream",
"UTF-8",
@ -417,7 +417,7 @@ class MainActivity : AppCompatActivity() {
)
}
} catch (e: Exception) {
Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e)
Log.e("WebViewProxy", "Error proxying request: $urlString - ${e.message}", e)
// For security, block the request rather than falling back to system handling
return WebResourceResponse("text/plain", "UTF-8", null)

View File

@ -1,15 +1,23 @@
package com.robosats
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import com.robosats.tor.TorKmpManager.getTorKmpObject
import android.annotation.SuppressLint
import android.text.TextUtils
import java.util.UUID
import okhttp3.OkHttpClient
import okhttp3.OkHttpClient.Builder
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.Objects
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern
import okhttp3.Request.Builder as RequestBuilder
/**
* Provides a secure bridge between JavaScript and native Android code.
@ -20,6 +28,7 @@ import java.util.regex.Pattern
class WebAppInterface(private val context: Context, private val webView: WebView) {
private val TAG = "WebAppInterface"
private val roboIdentities = RoboIdentities()
private val webSockets: MutableMap<String?, WebSocket?> = HashMap<String?, WebSocket?>()
// Security patterns for input validation
private val UUID_PATTERN = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE)
@ -40,61 +49,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
}
/**
* Validates that a string contains only safe characters and is within length limits
*/
private fun isValidInput(input: String?, maxLength: Int = MAX_INPUT_LENGTH): Boolean {
if (input == null || input.isEmpty() || input.length > maxLength) {
return false
}
return SAFE_STRING_PATTERN.matcher(input).matches()
}
/**
* Validates that a string is a valid UUID
*/
private fun isValidUuid(uuid: String?): Boolean {
if (uuid == null || uuid.isEmpty()) {
return false
}
return UUID_PATTERN.matcher(uuid).matches()
}
/**
* Safely evaluates JavaScript, escaping any potentially dangerous characters
*/
private fun safeEvaluateJavascript(script: String) {
// Remove any null bytes which could be used to trick the JS interpreter
val sanitizedScript = script.replace("\u0000", "")
webView.post {
try {
webView.evaluateJavascript(sanitizedScript, null)
} catch (e: Exception) {
Log.e(TAG, "Error evaluating JavaScript: $e")
}
}
}
/**
* Safely encodes a string for use in JavaScript
*/
private fun encodeForJavaScript(input: String): String {
return input.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("<", "\\u003C")
.replace(">", "\\u003E")
.replace("&", "\\u0026")
}
/**
* Generate a robot name from the given message
* @param uuid A unique identifier for the JavaScript Promise
* @param message The input message to generate a robot name from
*/
@JavascriptInterface
fun generateRoboname(uuid: String, message: String) {
// Validate inputs before processing
@ -119,37 +73,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
}
/**
* Helper function to safely resolve a JavaScript Promise
*/
private fun resolvePromise(uuid: String, result: String) {
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for promise resolution: $uuid")
return
}
val encodedResult = encodeForJavaScript(result)
safeEvaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('$uuid', '$encodedResult')")
}
/**
* Helper function to safely reject a JavaScript Promise
*/
private fun rejectPromise(uuid: String, errorMessage: String) {
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for promise rejection: $uuid")
return
}
val encodedError = encodeForJavaScript(errorMessage)
safeEvaluateJavascript("javascript:window.AndroidRobosats.onRejectPromise('$uuid', '$encodedError')")
}
/**
* Generate a robot hash from the given message
* @param uuid A unique identifier for the JavaScript Promise
* @param message The input message to generate a robot hash from
*/
@JavascriptInterface
fun generateRobohash(uuid: String, message: String) {
// Validate inputs before processing
@ -174,10 +97,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
}
/**
* Copy text to the clipboard
* @param message The text to copy to the clipboard
*/
@JavascriptInterface
fun copyToClipboard(message: String) {
// Validate input
@ -211,10 +130,6 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
}
/**
* Get the current Tor connection status
* @param uuid A unique identifier for the JavaScript Promise
*/
@JavascriptInterface
fun getTorStatus(uuid: String) {
// Validate UUID
@ -234,4 +149,165 @@ class WebAppInterface(private val context: Context, private val webView: WebView
rejectPromise(uuid, "Error retrieving Tor status")
}
}
@JavascriptInterface
fun openWS(uuid: String, path: String) {
// Validate UUID
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for getTorStatus: $uuid")
return
}
try {
Log.d(TAG, "WebSocket opening: " + path)
val client: OkHttpClient = Builder()
.connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout
.readTimeout(30, TimeUnit.SECONDS) // Set read timeout
.proxy(getTorKmpObject().proxy)
.build()
// Create a request for the WebSocket connection
val request: Request = RequestBuilder()
.url(path) // Replace with your WebSocket URL
.build()
// Create a WebSocket listener
val listener: WebSocketListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "WebSocket opened: " + response.message)
resolvePromise(uuid, "true")
synchronized(webSockets) {
webSockets.put(
path,
webSocket
) // Store the WebSocket instance with its URL
resolvePromise(uuid, path)
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
onWsMessage(path, text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
onWsMessage(path, bytes.hex())
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "WebSocket closing: " + reason)
onWsClose(path)
synchronized(webSockets) {
webSockets.remove(path) // Remove the WebSocket instance by URL
}
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?
) {
Log.d(TAG, "WebSocket error: " + t.message)
onWsError(path)
rejectPromise(uuid, "false")
}
}
client.newWebSocket(request, listener)
} catch (e: Exception) {
Log.e(TAG, "Error connecting to WebSocket", e)
rejectPromise(uuid, "Error connecting Tor WebSocket")
}
}
@JavascriptInterface
fun sendWsMessage(uuid: String, path: String, message: String) {
// Validate UUID
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for getTorStatus: $uuid")
return
}
val websocket = webSockets.get(path)
if (websocket != null) {
websocket.send(message)
resolvePromise(uuid, "true")
} else {
rejectPromise(uuid, "Error sending WebSocket message")
}
}
private fun onWsMessage(path: String?, message: String?) {
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWSMessage('$path', '$message')")
}
private fun onWsError(path: String?) {
Log.d(TAG, "WebSocket error: $path")
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWsError('$path')")
}
private fun onWsClose(path: String?) {
Log.d(TAG, "WebSocket close: $path")
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWsClose('$path')")
}
private fun resolvePromise(uuid: String, result: String) {
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for promise resolution: $uuid")
return
}
val encodedResult = encodeForJavaScript(result)
safeEvaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('$uuid', '$encodedResult')")
}
private fun rejectPromise(uuid: String, errorMessage: String) {
if (!isValidUuid(uuid)) {
Log.e(TAG, "Invalid UUID for promise rejection: $uuid")
return
}
val encodedError = encodeForJavaScript(errorMessage)
safeEvaluateJavascript("javascript:window.AndroidRobosats.onRejectPromise('$uuid', '$encodedError')")
}
private fun isValidInput(input: String?, maxLength: Int = MAX_INPUT_LENGTH): Boolean {
if (input == null || input.isEmpty() || input.length > maxLength) {
return false
}
return SAFE_STRING_PATTERN.matcher(input).matches()
}
private fun isValidUuid(uuid: String?): Boolean {
if (uuid == null || uuid.isEmpty()) {
return false
}
return UUID_PATTERN.matcher(uuid).matches()
}
private fun safeEvaluateJavascript(script: String) {
// Remove any null bytes which could be used to trick the JS interpreter
val sanitizedScript = script.replace("\u0000", "")
webView.post {
try {
webView.evaluateJavascript(sanitizedScript, null)
} catch (e: Exception) {
Log.e(TAG, "Error evaluating JavaScript: $e")
}
}
}
private fun encodeForJavaScript(input: String): String {
return input.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("<", "\\u003C")
.replace(">", "\\u003E")
.replace("&", "\\u0026")
}
}

View File

@ -11,6 +11,7 @@ activity = "1.10.1"
constraintlayout = "2.2.1"
kmpTor= "4.8.10-0-1.4.5"
kmpTorBinary= "4.8.10-0"
okhttp = "5.0.0-alpha.14"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -23,6 +24,7 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
kmp-tor = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor", version.ref = "kmpTor" }
kmp-tor-binary = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor-binary-extract-android", version.ref = "kmpTorBinary" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }