diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 12:14:01 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:54 +0200 |
| commit | 6ebfc86392a74dc0ae18f0d2703a3f91b8977d46 (patch) | |
| tree | 99cf5d8b2939a9cf3af16fe4258186e9d78ed7ce | |
| parent | 4e33fca55e48bbf6233e950355d31c603d88a6e6 (diff) | |
| download | meshbay-6ebfc86392a74dc0ae18f0d2703a3f91b8977d46.tar.gz | |
feat(android): client shell with the interface from the package
WebView over the packaged UI (copied from hub/static at build time), the
desktop CSP as a header, a bridge answering our top-level document only,
hub calls from native to the signed-in hub. Keys stay in the page for now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
26 files changed, 1560 insertions, 0 deletions
diff --git a/packages/meshbay-android/.gitignore b/packages/meshbay-android/.gitignore new file mode 100644 index 0000000..8a50ec5 --- /dev/null +++ b/packages/meshbay-android/.gitignore @@ -0,0 +1,6 @@ +# Generated — the interface is copied from meshbay-hub/static at build time +# and never committed (docs/MESHBAY_DESIGN.md §8.3). +build/ +.gradle/ +local.properties +.kotlin/ diff --git a/packages/meshbay-android/README.md b/packages/meshbay-android/README.md new file mode 100644 index 0000000..8c357fe --- /dev/null +++ b/packages/meshbay-android/README.md @@ -0,0 +1,22 @@ +# MeshBay — Android client + +A client, not a host: no node runs on a phone (`docs/MESHBAY_DESIGN.md` §11.3). + +The shell is a system WebView showing the interface **from the package** — +`meshbay-hub/src/meshbay_hub/static/` copied at build time into +`build/generated/`, never committed (§8.3) — with a bridge +(`app/src/main/assets/bridge/meshbay-bridge.js`) that offers the page the same +`window.meshbay` as the desktop preload, wherever it offers anything at all. +Hub calls leave from native code, to the signed-in hub only. + +```bash +# needs JDK 17+ and an Android SDK (ANDROID_HOME, or sdk.dir in local.properties) +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew testDebugUnitTest # JVM unit tests +``` + +The security contract is also pinned from the Python suite by reading this +source: `packages/meshbay-hub/tests/test_android_shell.py`. + +Not built yet: native keys, downloads to disk, casting, phone-specific +behaviour (back button, network handover), signed releases. diff --git a/packages/meshbay-android/app/build.gradle.kts b/packages/meshbay-android/app/build.gradle.kts new file mode 100644 index 0000000..5364ec9 --- /dev/null +++ b/packages/meshbay-android/app/build.gradle.kts @@ -0,0 +1,74 @@ +import groovy.json.JsonSlurper + +plugins { id("com.android.application") } + +// One version for every package (CLAUDE.md): read from the desktop client's +// package.json rather than written a second time here. +val packageVersion = (JsonSlurper().parse(rootDir.resolve("../meshbay-client/package.json")) + as Map<*, *>)["version"] as String +val versionParts = packageVersion.split(".").map { it.toInt() } + +android { + namespace = "org.meshbay.client" + compileSdk = 37 + defaultConfig { + applicationId = "org.meshbay.client" + minSdk = 26 + targetSdk = 36 + versionName = packageVersion + versionCode = versionParts[0] * 10000 + versionParts[1] * 100 + versionParts[2] + } + buildTypes { + getByName("release") { isMinifyEnabled = false } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { buildConfig = true } + testOptions { unitTests.isReturnDefaultValues = false } +} + +dependencies { + implementation("androidx.webkit:webkit:1.17.1") + implementation("com.squareup.okhttp3:okhttp:5.5.0") + testImplementation("junit:junit:4.13.2") + // Android's org.json is a stub on the JVM; the unit tests need the real one. + testImplementation("org.json:json:20260814") +} + +/** + * The interface, copied from its single source at build time (§8.3). + * + * `meshbay-hub/src/meshbay_hub/static/` is the interface for the web, the + * desktop application and this one. The copy lands in build/ and is never + * committed, so it cannot fork. The page itself is the desktop application's + * `scripts/index.html` — the hub builds its own with a /a/<hash>/ prefix that + * would point back at the hub — so an application loading from its package + * has one page, not two. + */ +abstract class SyncUi : DefaultTask() { + @get:InputDirectory abstract val staticDir: DirectoryProperty + @get:InputFile abstract val indexHtml: RegularFileProperty + @get:OutputDirectory abstract val outputDir: DirectoryProperty + + @TaskAction + fun copy() { + val ui = outputDir.get().asFile.resolve("ui") + outputDir.get().asFile.deleteRecursively() + staticDir.get().asFile.copyRecursively(ui) + indexHtml.get().asFile.copyTo(ui.resolve("index.html"), overwrite = true) + } +} + +val syncUi = tasks.register<SyncUi>("syncUi") { + staticDir.set(rootDir.resolve("../meshbay-hub/src/meshbay_hub/static")) + indexHtml.set(rootDir.resolve("../meshbay-client/scripts/index.html")) + outputDir.set(layout.buildDirectory.dir("generated/ui-assets")) +} + +androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory(syncUi, SyncUi::outputDir) + } +} diff --git a/packages/meshbay-android/app/src/main/AndroidManifest.xml b/packages/meshbay-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..82b827e --- /dev/null +++ b/packages/meshbay-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + + <!-- No backup of any kind: the keys are wrapped by a Keystore key that a + restore cannot bring with it, so a backed-up store is one that silently + fails to open on the next device. --> + <application + android:label="MeshBay" + android:allowBackup="false" + android:fullBackupContent="false" + android:dataExtractionRules="@xml/data_extraction_rules" + android:networkSecurityConfig="@xml/network_security_config" + android:theme="@style/Shell"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:windowSoftInputMode="adjustResize" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|keyboard|keyboardHidden|density|navigation"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js new file mode 100644 index 0000000..350e087 --- /dev/null +++ b/packages/meshbay-android/app/src/main/assets/bridge/meshbay-bridge.js @@ -0,0 +1,84 @@ +/** + * The bridge, and the whole of it — the Android counterpart of + * meshbay-client/src/preload.js, with the same shape wherever it offers + * something at all. + * + * Injected at document start into documents of the packaged origin, before any + * page script. It takes the native port the listener injected, hides the + * global, and exposes `window.meshbay` frozen. There is no context isolation + * on Android: page script runs in the same world, so what this buys is that + * nothing can reach the raw port by name, not that this file is out of reach. + * The confinement that matters is native — the listener answers the packaged + * origin's top-level document only, and checks every argument. + * + * What the desktop offers and this build does not is ABSENT, not a function + * that refuses: `platform.js` decides what to show from whether an object + * exists (`platform.node.available`, `platform.folder.available`, …). + * + * `HUB_BASE` is prepended by the shell when it injects this file: the + * interface asks for it while its modules load, before anything can await. + */ +(function () { + 'use strict'; + const port = window.meshbayNative; + try { delete window.meshbayNative; } catch (e) { /* already gone */ } + // A same-origin child frame gets the port too; it gets no bridge, and native + // refuses whatever it sends anyway. + if (!port || window.top !== window) return; + + const pending = new Map(); + let seq = 0; + port.onmessage = (event) => { + let reply; + try { reply = JSON.parse(event.data); } catch (e) { return; } + const waiter = pending.get(reply.id); + if (!waiter) return; + pending.delete(reply.id); + if (reply.ok) waiter.resolve(reply.value); + else waiter.reject(new Error(reply.error)); + }; + const call = (channel, ...args) => new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, { resolve, reject }); + port.postMessage(JSON.stringify({ id, ch: channel, args })); + }); + + const meshbay = { + hubBase: () => HUB_BASE, + setHubBase: (base) => call('hub:set', base), + + capabilities: { + nodeAdmin: false, // no node runs on a phone (§11.3) + localFolders: false, + nativeSave: false, // phase 2 + lanCast: false, // phase 3 + tray: false, + }, + + setLocale: (code) => call('ui:locale', code), + + // The page's origin is refused by the hub's absent CORS, and is not a + // credential anyway: native goes, to the signed-in hub only. + fetch: (url, init) => call('hub:fetch', url, init), + + resolveStun: (urls) => call('ice:resolve-stun', urls), + }; + + const freeze = (o) => { + Object.freeze(o); + for (const v of Object.values(o)) if (v && typeof v === 'object' && !Object.isFrozen(v)) freeze(v); + return o; + }; + Object.defineProperty(window, 'meshbay', { + value: freeze(meshbay), writable: false, configurable: false, enumerable: false, + }); + + // The WebView exposes File System Access and cannot back it with anything a + // person can see. Left in place, `downloads.SUPPORTED` reads true and a + // download could take a path that fails — or reach the blob floor silently. + for (const name of ['showDirectoryPicker', 'showSaveFilePicker', 'showOpenFilePicker']) { + try { + Object.defineProperty(window, name, { value: undefined, writable: false, configurable: false }); + } catch (e) { /* not definable: leave it, native save comes first anyway */ } + } +})(); diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt new file mode 100644 index 0000000..f6f9740 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt @@ -0,0 +1,202 @@ +package org.meshbay.client + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.webkit.ConsoleMessage +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewClientCompat +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import org.json.JSONObject +import org.meshbay.client.bridge.Bridge +import org.meshbay.client.bridge.Channels +import org.meshbay.client.hub.HubClient +import org.meshbay.client.shell.EngineCheck +import org.meshbay.client.shell.UiAssets + +/** + * The shell: one WebView showing the packaged interface, and the bridge. + * + * What this file must never do: load anything into the WebView that is not the + * package (the hub never becomes the document origin — T3), or hand the page a + * way to the hub other than the bridge. + */ +class MainActivity : Activity() { + private lateinit var root: FrameLayout + private lateinit var web: WebView + private lateinit var hub: HubClient + private var shim: ScriptHandler? = null + private var fullscreen: View? = null + private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + root = FrameLayout(this) + setContentView(root) + applyInsets(root) + + // A WebView too old for the page's crypto would fail at the first + // handshake; say so before loading anything. + EngineCheck.problem(this)?.let { setContentView(EngineCheck.screen(this, it)); return } + + hub = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)) + web = WebView(this) + root.addView(web, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + configure(web) + + val channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } }, + hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }) + WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels)) + installShim() + web.loadUrl(UiAssets.START) + } + + private fun configure(web: WebView) { + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + web.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true // IndexedDB and localStorage: session, resume positions + allowFileAccess = false + allowContentAccess = false + mediaPlaybackRequiresUserGesture = true + setSupportMultipleWindows(false) + mixedContentMode = android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW + } + android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(web, false) + + val loader = WebViewAssetLoader.Builder() + .setDomain(UiAssets.HOST) + .addPathHandler(UiAssets.PREFIX, UiAssets(this)) + .build() + web.webViewClient = object : WebViewClientCompat() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + val url = request.url + if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused() + // reCAPTCHA (sign-up) and nothing else goes to the network from + // the page; the policy says the same, this is the second wall. + if (UiAssets.isRecaptcha(url.host) && url.scheme == "https") return null + if (url.scheme == "blob" || url.scheme == "data") return null + return refused() + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url + if (url.host == UiAssets.HOST) return false + // The hub must never become the document origin. A link out + // opens in the person's browser, not in a window holding keys. + if (request.isForMainFrame && (url.scheme == "https" || url.scheme == "http")) openExternally(url) + return !(UiAssets.isRecaptcha(url.host) && !request.isForMainFrame) + } + } + web.webChromeClient = object : WebChromeClient() { + // Grant by enumeration: nothing. Camera, microphone, MIDI and + // whatever Chromium adds next arrive refused. + override fun onPermissionRequest(request: PermissionRequest) = request.deny() + + // Without this, a video's requestFullscreen() never settles — a + // refusal that never rejects (CLAUDE.md). Measured in the spike. + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + fullscreen?.let { root.removeView(it) } + fullscreen = view + fullscreenCallback = callback + root.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + web.visibility = View.INVISIBLE + setFullscreenBars(true) + } + + override fun onHideCustomView() { + fullscreen?.let { root.removeView(it) } + fullscreen = null + fullscreenCallback = null + web.visibility = View.VISIBLE + setFullscreenBars(false) + } + + override fun onConsoleMessage(m: ConsoleMessage): Boolean { + if (BuildConfig.DEBUG) Log.i("MeshBayPage", "${m.messageLevel()} ${m.message()} @${m.sourceId()}:${m.lineNumber()}") + return true + } + } + } + + /** + * The shim, with the hub address in it: the page reads that synchronously + * while its modules load. Changing the hub replaces the shim and reloads — + * a page left running would go on talking to the old hub with no sign of it. + */ + private fun installShim() { + shim?.remove() + val source = assets.open("bridge/meshbay-bridge.js").bufferedReader().use { it.readText() } + val prelude = "const HUB_BASE = ${JSONObject.quote(hub.base)};\n" + shim = WebViewCompat.addDocumentStartJavaScript(web, "(function(){$prelude$source\n})();", setOf(UiAssets.ORIGIN)) + } + + private fun reloadForHub() { + installShim() + web.loadUrl(UiAssets.START) + } + + private fun hasAsset(path: String) = try { assets.open(path).close(); true } catch (e: java.io.IOException) { false } + + private fun refused() = WebResourceResponse("text/plain", "utf-8", 403, "Forbidden", emptyMap(), "".byteInputStream()) + + private fun openExternally(url: Uri) { + try { startActivity(Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE)) } + catch (e: android.content.ActivityNotFoundException) { Log.w(Bridge.TAG, "no browser for $url") } + } + + /** Edge-to-edge is enforced from Android 15: keep the page clear of the bars and the keyboard. */ + private fun applyInsets(view: View) { + view.setOnApplyWindowInsetsListener { v, insets -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bars = if (fullscreen != null) android.graphics.Insets.NONE + else insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime() or WindowInsets.Type.displayCutout()) + v.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + v.setPadding(insets.systemWindowInsetLeft, insets.systemWindowInsetTop, + insets.systemWindowInsetRight, insets.systemWindowInsetBottom) + } + insets + } + } + + private fun setFullscreenBars(on: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val c = window.insetsController ?: return + if (on) { + c.hide(WindowInsets.Type.systemBars()) + c.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else c.show(WindowInsets.Type.systemBars()) + } + root.requestApplyInsets() + } + + @Deprecated("Back is handed to the page in phase 4; until then it leaves fullscreen or backgrounds the app.") + override fun onBackPressed() { + if (fullscreen != null) { fullscreenCallback?.onCustomViewHidden(); return } + if (web.canGoBack()) { web.goBack(); return } + // Never finish(): that would tear down every connection and transfer. + moveTaskToBack(true) + } + + override fun onDestroy() { + if (::web.isInitialized) { root.removeView(web); web.destroy() } + super.onDestroy() + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt new file mode 100644 index 0000000..50f711c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Bridge.kt @@ -0,0 +1,65 @@ +package org.meshbay.client.bridge + +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import org.json.JSONArray +import org.json.JSONObject +import org.meshbay.client.shell.UiAssets +import java.util.concurrent.Executors + +/** + * Everything the interface may ask of the application, and the only way in. + * + * `addWebMessageListener` injects `meshbayNative` only into documents of the + * packaged origin; the shim (assets/bridge/meshbay-bridge.js) takes it at + * document start and hides it. But a same-origin child frame gets one too — the + * spike measured it — so what actually confines the bridge is the check here: + * **the packaged origin's top-level document, and nothing else** (main.js + * `fromOurPage`). The page parses decrypted content from nodes, which is + * attacker-controlled input, so every argument is checked again in Channels. + */ +class Bridge(private val channels: Channels) : WebViewCompat.WebMessageListener { + + private val main = Handler(Looper.getMainLooper()) + // Hub calls and key operations block; none may run on the UI thread. + private val work = Executors.newCachedThreadPool() + + override fun onPostMessage(view: WebView, message: WebMessageCompat, sourceOrigin: Uri, + isMainFrame: Boolean, replyProxy: JavaScriptReplyProxy) { + val request = try { JSONObject(message.data ?: return) } catch (e: Exception) { return } + val id = request.optLong("id", -1) + if (!isMainFrame || sourceOrigin.toString() != UiAssets.ORIGIN) { + Log.w(TAG, "refused a message from $sourceOrigin (main frame: $isMainFrame)") + replyProxy.postMessage(error(id, "Refused: not the MeshBay interface")) + return + } + val channel = request.optString("ch") + val args = request.optJSONArray("args") ?: JSONArray() + work.execute { + val reply = try { + JSONObject().put("id", id).put("ok", true).put("value", channels.call(channel, args) ?: JSONObject.NULL) + .toString() + } catch (e: Refused) { + error(id, e.message ?: "Refused") + } catch (e: Exception) { + Log.w(TAG, "$channel failed", e) + error(id, e.message ?: e.javaClass.simpleName) + } + main.post { replyProxy.postMessage(reply) } + } + } + + private fun error(id: Long, message: String) = + JSONObject().put("id", id).put("ok", false).put("error", message).toString() + + companion object { + const val TAG = "MeshBay" + const val PORT = "meshbayNative" + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt new file mode 100644 index 0000000..81be25e --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt @@ -0,0 +1,63 @@ +package org.meshbay.client.bridge + +import org.json.JSONArray +import org.meshbay.client.hub.HubClient +import java.net.Inet4Address +import java.net.InetAddress + +/** + * The enumerated channels, and nothing else (main.js `registerBridge`). + * + * A channel that takes a path, a URL to anywhere, or bytes to sign from the + * page is the shape to avoid. What the desktop offers and a phone does not — + * the local node, shared folders, the tray — is not here at all, and the shim + * does not offer it either: `platform.js` decides what to show from whether a + * bridge object exists, so an object that only refused would put screens on + * the page that fail when used. + */ +class Channels( + private val hub: HubClient, + private val onHubChanged: () -> Unit, + private val hasCatalogue: (String) -> Boolean, +) { + @Volatile var locale = "en" + private set + + fun call(channel: String, args: JSONArray): Any? = when (channel) { + "hub:set" -> hub.setBase(args.optString(0, "")).also { onHubChanged() } + "hub:fetch" -> hub.fetch(args.optString(0, ""), args.optJSONObject(1)) + "ice:resolve-stun" -> resolveStun(args.optJSONArray(0) ?: JSONArray()) + "ui:locale" -> setLocale(args.optString(0, "")) + else -> throw Refused("Refused: no such channel") + } + + private fun setLocale(code: String): String { + // A code, never text, and only one the package has a catalogue for. + if (LOCALE.matches(code) && hasCatalogue(code)) locale = code + return locale + } + + companion object { + private val LOCALE = Regex("^[a-z]{2}(-[A-Z]{2})?$") + private val STUN = Regex("^(stuns?):(\\[?[^\\]]+\\]?|[^:]+):(\\d+)$") + + /** + * `stun:host:port` → `stun:ip:port`. Chromium's socket manager fails + * STUN hostnames outright behind some resolvers, and the page cannot do + * DNS. Unresolvable entries are dropped, literals pass through. + */ + fun resolveStun(urls: JSONArray, lookup: (String) -> Array<InetAddress> = InetAddress::getAllByName): JSONArray { + val out = JSONArray() + for (i in 0 until urls.length()) { + val u = urls.optString(i) + val m = STUN.matchEntire(u) + if (m == null) { out.put(u); continue } + val (scheme, host, port) = m.destructured + if (Regex("^[\\d.]+$").matches(host) || host.contains(':')) { out.put(u); continue } + val ip = try { lookup(host).firstOrNull { it is Inet4Address }?.hostAddress } catch (e: Exception) { null } + if (ip != null) out.put("$scheme:$ip:$port") + } + return out + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt new file mode 100644 index 0000000..6f550a5 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Refused.kt @@ -0,0 +1,4 @@ +package org.meshbay.client.bridge + +/** A refusal whose message is written for a person; it reaches the page as is. */ +class Refused(message: String) : Exception(message) diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt new file mode 100644 index 0000000..3b8517c --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/hub/HubClient.kt @@ -0,0 +1,130 @@ +package org.meshbay.client.hub + +import android.content.SharedPreferences +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.meshbay.client.bridge.Refused +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException + +/** + * Every call to the hub leaves from here, never from the page. + * + * Not a preference: the page's origin is `https://appassets.androidplatform.net`, + * which the hub's absent CORS refuses — and that posture is worth keeping, its + * API is reachable from no web origin at all. So the page asks and this goes, + * to the hub it is signed in to and nowhere else (main.js `hub:fetch`). + */ +class HubClient(private val prefs: SharedPreferences) { + + private val http = OkHttpClient.Builder() + // The hub's longest call is signaling, which gives up at fifteen + // seconds; past this, no answer is still coming (HUB_FETCH_TIMEOUT_MS). + .callTimeout(FETCH_TIMEOUT_S, TimeUnit.SECONDS) + .followRedirects(false) + .build() + + val base: String get() = prefs.getString(KEY_BASE, "") ?: "" + + /** Check that the address answers as a hub before writing it down. */ + fun setBase(raw: String): String { + val url = raw.trim().trimEnd('/') + // An empty address is not "no hub": main.js probes it like any other + // and it fails, so the first-run screen cannot be passed with nothing. + if (url.isEmpty()) throw Refused("Enter the address of a hub.") + if (!url.startsWith("https://") && !LOOPBACK_HTTP.containsMatchIn(url)) { + // http only to this device's loopback; anywhere else it would put + // the session token on the wire in clear. + throw Refused("The hub address must be https") + } + val probe = url.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/hub/version")?.build() + ?: throw Refused("$url is not an address") + val answer = try { + http.newBuilder().callTimeout(PROBE_TIMEOUT_S, TimeUnit.SECONDS).build() + .newCall(Request.Builder().url(probe).build()).execute().use { r -> + if (!r.isSuccessful) throw IOException("answered ${r.code}") + JSONObject(r.body.string()) + } + } catch (e: Exception) { + throw Refused(describeUnreachable(url, e)) + } + if (!answer.has("hub")) throw Refused(describeUnreachable(url, IOException("did not answer as a hub"))) + prefs.edit().putString(KEY_BASE, url).apply() + return url + } + + /** `{status, ok, headers, body}`, the shape main.js returns and platform.apiFetch reads. */ + fun fetch(url: String, init: JSONObject?): JSONObject { + val target = url.toHttpUrlOrNull() ?: throw Refused("not an address") + val hub = base.toHttpUrlOrNull() + // The page may only reach the hub it is signed in to: a path it + // controls must not become a request to somewhere else. + if (hub == null || !sameOrigin(target, hub)) throw Refused("Refused: not this hub") + + val method = (init?.optString("method").takeUnless { it.isNullOrEmpty() } ?: "GET").uppercase() + val builder = Request.Builder().url(target) + var contentType: String? = null + init?.optJSONObject("headers")?.let { h -> + for (name in h.keys()) { + val value = h.get(name).toString() + if (name.equals("content-type", ignoreCase = true)) contentType = value + builder.header(name, value) + } + } + val text = init?.opt("body")?.takeUnless { it == JSONObject.NULL }?.toString() + val body = when { + method == "GET" || method == "HEAD" -> null + else -> (text ?: "").toRequestBody(contentType?.toMediaTypeOrNull()) + } + builder.method(method, body) + + return try { + http.newCall(builder.build()).execute().use { r -> + val headers = JSONObject() + for (name in r.headers.names()) headers.put(name.lowercase(), r.headers.values(name).joinToString(", ")) + JSONObject().put("status", r.code).put("ok", r.isSuccessful) + .put("headers", headers).put("body", r.body.string()) + } + } catch (e: IOException) { + // OkHttp's call timeout is an InterruptedIOException("timeout"), a + // read timeout a SocketTimeoutException; both mean the same thing. + if (e is SocketTimeoutException || e.message?.contains("timeout", ignoreCase = true) == true) { + throw Refused("${originOf(hub)} accepted the connection but did not answer within ${FETCH_TIMEOUT_S}s.") + } + throw Refused(describeUnreachable(originOf(hub), e)) + } + } + + companion object { + private const val KEY_BASE = "hubBase" + const val FETCH_TIMEOUT_S = 30L + private const val PROBE_TIMEOUT_S = 10L + private val LOOPBACK_HTTP = Regex("^http://(localhost|127\\.)") + + fun sameOrigin(a: HttpUrl, b: HttpUrl) = a.scheme == b.scheme && a.host == b.host && a.port == b.port + + private fun originOf(u: HttpUrl): String { + val defaultPort = (u.scheme == "https" && u.port == 443) || (u.scheme == "http" && u.port == 80) + return "${u.scheme}://${u.host}" + if (defaultPort) "" else ":${u.port}" + } + + /** Why the hub could not be reached, in words somebody can act on (main.js). */ + fun describeUnreachable(url: String, e: Throwable): String = when { + url.startsWith("https:") && e is SSLException -> + "$url does not speak https. If this hub is on your own machine, it is probably http — try http:// instead." + e is ConnectException -> "Nothing is listening at $url. Is the hub running?" + e is UnknownHostException -> "$url could not be found. Check the address." + e is SocketTimeoutException || e.message?.contains("timeout", true) == true -> "$url did not answer in time." + else -> "Could not reach $url: ${e.message ?: e.javaClass.simpleName}" + } + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt new file mode 100644 index 0000000..6382182 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/EngineCheck.kt @@ -0,0 +1,57 @@ +package org.meshbay.client.shell + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +/** + * The engine floor, checked before the page is loaded (design O6: verified, + * not assumed). + * + * The WebView updates through the store independently of Android, so the floor + * is a Chromium version, not an API level. What binds it: Ed25519 and X25519 in + * WebCrypto (the handshake, chat and the group envelope) — Chromium 137 — and + * the two androidx.webkit features the bridge is built on. Measured present on + * WebView 145 (spike S-1); the floor itself still has to be confirmed on the + * oldest real device to be supported. + */ +object EngineCheck { + const val MIN_CHROMIUM = 137 + + fun problem(context: Context): String? { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) || + !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + return "This device's Android System WebView is too old for MeshBay." + } + val version = WebViewCompat.getCurrentWebViewPackage(context)?.versionName ?: return null + val major = version.substringBefore('.').toIntOrNull() ?: return null + return if (major < MIN_CHROMIUM) { + "MeshBay needs Android System WebView $MIN_CHROMIUM or newer; this device has $version." + } else null + } + + fun screen(context: Context, problem: String): View = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(48, 48, 48, 48) + addView(TextView(context).apply { text = problem; textSize = 18f; gravity = Gravity.CENTER }) + addView(Button(context).apply { + text = "Update Android System WebView" + setOnClickListener { + val id = "com.google.android.webview" + try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$id"))) } + catch (e: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$id"))) + } + } + }) + } +} diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt new file mode 100644 index 0000000..2300668 --- /dev/null +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/shell/UiAssets.kt @@ -0,0 +1,93 @@ +package org.meshbay.client.shell + +import android.content.Context +import android.webkit.WebResourceResponse +import androidx.webkit.WebViewAssetLoader +import java.io.File + +/** + * Serves the packaged interface, and nothing else. + * + * The page's origin is `https://appassets.androidplatform.net` — a secure + * context, without which `crypto.subtle` does not exist — and every file comes + * out of the APK's `assets/ui/`, copied from the hub's static directory at build + * time (§8.3). The hub never becomes the document origin: that is the whole + * reason the application exists (T3). + * + * The stock asset handler sets no headers, so this one exists for three: the + * policy, sent as a header because a <meta> policy drops `frame-ancestors`; + * `nosniff`; and `no-store`, since every file is already local. + */ +class UiAssets(private val context: Context) : WebViewAssetLoader.PathHandler { + + override fun handle(path: String): WebResourceResponse? { + // Asset paths are not a filesystem, but a `..` that reached + // AssetManager would still be a path the page chose; refuse it. + if (path.split('/').any { it == ".." || it == "." } || path.startsWith("/")) return notFound() + val asset = "ui/" + path.ifEmpty { "index.html" } + val stream = try { context.assets.open(asset) } catch (e: java.io.IOException) { return notFound() } + val headers = mapOf( + "Content-Security-Policy" to CSP, + "X-Content-Type-Options" to "nosniff", + "Cache-Control" to "no-store", + ) + val type = contentType(asset) + val charset = if (type.startsWith("text/") || type == "application/json") "utf-8" else null + return WebResourceResponse(type, charset, 200, "OK", headers, stream) + } + + private fun notFound() = + WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), "".byteInputStream()) + + companion object { + const val HOST = "appassets.androidplatform.net" + const val ORIGIN = "https://$HOST" + const val PREFIX = "/ui/" + const val START = "$ORIGIN${PREFIX}index.html" + + // reCAPTCHA gates sign-up here as it does in a browser and on the + // desktop; these two hosts and no others. + private const val RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com" + + /** + * meshbay-client/src/main.js's CSP, directive for directive + * (test_android_shell.py holds them together). `'wasm-unsafe-eval'` is + * the Argon2 that opens bundles: without it nobody reaches their keys. + */ + val CSP = listOf( + "default-src 'none'", + "script-src 'self' 'wasm-unsafe-eval' $RECAPTCHA_SRC", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: $RECAPTCHA_SRC", + "media-src 'self' blob:", + "font-src 'self'", + "connect-src 'self' $RECAPTCHA_SRC", + "worker-src 'self'", + "object-src blob:", + "frame-src blob: $RECAPTCHA_SRC", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'", + ).joinToString("; ") + + fun isRecaptcha(host: String?) = host == "www.google.com" || host == "www.gstatic.com" + + fun contentType(name: String): String = when (File(name).extension.lowercase()) { + "html" -> "text/html" + "js", "mjs" -> "text/javascript" + "css" -> "text/css" + "json" -> "application/json" + "wasm" -> "application/wasm" + "svg" -> "image/svg+xml" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "ico" -> "image/x-icon" + "webp" -> "image/webp" + "woff2" -> "font/woff2" + "woff" -> "font/woff" + "txt" -> "text/plain" + "xml" -> "application/xml" + else -> "application/octet-stream" + } + } +} diff --git a/packages/meshbay-android/app/src/main/res/values/themes.xml b/packages/meshbay-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a7df056 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Shell" parent="@android:style/Theme.DeviceDefault.NoActionBar"> + <item name="android:windowBackground">@android:color/black</item> + </style> +</resources> diff --git a/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..f0abf11 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<data-extraction-rules> + <cloud-backup> + <exclude domain="root" /><exclude domain="file" /><exclude domain="database" /> + <exclude domain="sharedpref" /><exclude domain="external" /> + </cloud-backup> + <device-transfer> + <exclude domain="root" /><exclude domain="file" /><exclude domain="database" /> + <exclude domain="sharedpref" /><exclude domain="external" /> + </device-transfer> +</data-extraction-rules> diff --git a/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8bf3e82 --- /dev/null +++ b/packages/meshbay-android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Plain http only to a hub on this device's own loopback — the desktop rule + ("http only to localhost or 127.*"). Anywhere else a session token would + cross the network in clear. --> +<network-security-config> + <base-config cleartextTrafficPermitted="false" /> + <domain-config cleartextTrafficPermitted="true"> + <domain includeSubdomains="false">localhost</domain> + <domain includeSubdomains="false">127.0.0.1</domain> + </domain-config> +</network-security-config> diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt new file mode 100644 index 0000000..adc6366 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/ChannelsTest.kt @@ -0,0 +1,39 @@ +package org.meshbay.client + +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.meshbay.client.bridge.Channels +import org.meshbay.client.bridge.Refused +import org.meshbay.client.hub.HubClient +import java.net.InetAddress +import java.net.UnknownHostException + +class ChannelsTest { + private val channels = Channels(HubClient(FakePrefs()), onHubChanged = {}, hasCatalogue = { it == "fr" || it == "pt-BR" }) + + @Test fun `a channel that is not enumerated is refused`() { + for (ch in listOf("node:op", "root:choose", "window:minimize-to-tray", "keys:sign", "", "hub:fetch2")) { + assertThrows(ch, Refused::class.java) { channels.call(ch, JSONArray()) } + } + } + + @Test fun `the locale is a code with a catalogue, never text`() { + assertEquals("fr", channels.call("ui:locale", JSONArray().put("fr"))) + assertEquals("fr", channels.call("ui:locale", JSONArray().put("de"))) // no catalogue + assertEquals("fr", channels.call("ui:locale", JSONArray().put("../en"))) // not a code + assertEquals("pt-BR", channels.call("ui:locale", JSONArray().put("pt-BR"))) + } + + @Test fun `stun hostnames are resolved, literals kept, failures dropped`() { + val lookup: (String) -> Array<InetAddress> = { host -> + if (host == "stun.example") arrayOf(InetAddress.getByAddress(host, byteArrayOf(192.toByte(), 0, 2, 7))) + else throw UnknownHostException(host) + } + val out = Channels.resolveStun(JSONArray(listOf("stun:stun.example:3478", "stun:198.51.100.1:3478", + "stun:[2001:db8::1]:3478", "stun:gone.example:3478", "turn:x")), lookup) + assertEquals(listOf("stun:192.0.2.7:3478", "stun:198.51.100.1:3478", "stun:[2001:db8::1]:3478", "turn:x"), + (0 until out.length()).map { out.getString(it) }) + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt new file mode 100644 index 0000000..33d1c0f --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/FakePrefs.kt @@ -0,0 +1,30 @@ +package org.meshbay.client + +import android.content.SharedPreferences + +/** SharedPreferences is an interface; a map is enough for the JVM tests. */ +class FakePrefs : SharedPreferences { + val map = HashMap<String, Any?>() + override fun getAll(): MutableMap<String, *> = map + override fun getString(key: String, defValue: String?) = map[key] as String? ?: defValue + override fun getStringSet(key: String, defValues: MutableSet<String>?) = defValues + override fun getInt(key: String, defValue: Int) = map[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long) = map[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float) = map[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean) = map[key] as Boolean? ?: defValue + override fun contains(key: String) = map.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + override fun putString(k: String, v: String?) = apply { map[k] = v } + override fun putStringSet(k: String, v: MutableSet<String>?) = apply { map[k] = v } + override fun putInt(k: String, v: Int) = apply { map[k] = v } + override fun putLong(k: String, v: Long) = apply { map[k] = v } + override fun putFloat(k: String, v: Float) = apply { map[k] = v } + override fun putBoolean(k: String, v: Boolean) = apply { map[k] = v } + override fun remove(k: String) = apply { map.remove(k) } + override fun clear() = apply { map.clear() } + override fun commit() = true + override fun apply() {} + } +} diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt new file mode 100644 index 0000000..8211013 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/HubClientTest.kt @@ -0,0 +1,43 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.bridge.Refused +import org.meshbay.client.hub.HubClient + +class HubClientTest { + private fun hub(base: String = "") = HubClient(FakePrefs().apply { map["hubBase"] = base }) + + @Test fun `an empty address is refused, not stored as no hub`() { + val e = assertThrows(Refused::class.java) { hub().setBase(" ") } + assertEquals("Enter the address of a hub.", e.message) + } + + @Test fun `plain http is refused except to loopback`() { + for (url in listOf("http://example.org", "http://10.0.2.2:8770", "ftp://x", "http://192.168.1.2")) { + val e = assertThrows(Refused::class.java) { hub().setBase(url) } + assertEquals(url, "The hub address must be https", e.message) + } + } + + @Test fun `the page reaches the signed-in hub and nowhere else`() { + val h = hub("https://hub.example") + for (url in listOf("https://other.example/v1/x", "http://hub.example/v1/x", + "https://hub.example:8443/v1/x", "https://hub.example.evil/v1/x", "not a url")) { + assertThrows(url, Refused::class.java) { h.fetch(url, JSONObject()) } + } + } + + @Test fun `nothing is fetched before a hub is set`() { + assertThrows(Refused::class.java) { hub("").fetch("https://hub.example/v1/x", null) } + } + + @Test fun `unreachable hubs are described in words somebody can act on`() { + assertTrue(HubClient.describeUnreachable("https://h", java.net.ConnectException()).startsWith("Nothing is listening at https://h")) + assertTrue(HubClient.describeUnreachable("https://h", java.net.UnknownHostException()).contains("could not be found")) + assertTrue(HubClient.describeUnreachable("https://h", javax.net.ssl.SSLHandshakeException("x")).contains("does not speak https")) + } +} diff --git a/packages/meshbay-android/build.gradle.kts b/packages/meshbay-android/build.gradle.kts new file mode 100644 index 0000000..a4370dd --- /dev/null +++ b/packages/meshbay-android/build.gradle.kts @@ -0,0 +1 @@ +plugins { id("com.android.application") version "9.4.1" apply false } diff --git a/packages/meshbay-android/gradle.properties b/packages/meshbay-android/gradle.properties new file mode 100644 index 0000000..660848f --- /dev/null +++ b/packages/meshbay-android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g +android.useAndroidX=true diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..5097068 --- /dev/null +++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.jar diff --git a/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9f3a241 --- /dev/null +++ b/packages/meshbay-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,10 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c diff --git a/packages/meshbay-android/gradlew b/packages/meshbay-android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/packages/meshbay-android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/meshbay-android/gradlew.bat b/packages/meshbay-android/gradlew.bat new file mode 100644 index 0000000..3185a43 --- /dev/null +++ b/packages/meshbay-android/gradlew.bat @@ -0,0 +1,112 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. + +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel + +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/packages/meshbay-android/settings.gradle.kts b/packages/meshbay-android/settings.gradle.kts new file mode 100644 index 0000000..cead413 --- /dev/null +++ b/packages/meshbay-android/settings.gradle.kts @@ -0,0 +1,9 @@ +pluginManagement { + repositories { google(); mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "meshbay-android" +include(":app") diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py new file mode 100644 index 0000000..b2e0e4d --- /dev/null +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -0,0 +1,210 @@ +""" +The Android shell's security contract, pinned by reading its source. + +The same treatment `test_desktop_shell.py` gives the Electron application, for +the same reason: an emulator or a phone is what proves the shell runs, and the +suite has neither. What this proves is that the properties the design depends +on (docs/MESHBAY_DESIGN.md §8.2, as the Android plan restates them) are in the +source, and it fails when one is removed. The JVM unit tests run too when an +Android SDK is present. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +PACKAGES = Path(__file__).resolve().parents[2] +ANDROID = PACKAGES / "meshbay-android" +APP = ANDROID / "app" +SRC = APP / "src" / "main" / "kotlin" / "org" / "meshbay" / "client" +SHIM = APP / "src" / "main" / "assets" / "bridge" / "meshbay-bridge.js" +CLIENT = PACKAGES / "meshbay-client" + +pytestmark = pytest.mark.skipif(not ANDROID.exists(), reason="android sources not present") + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _kotlin() -> str: + return "\n".join(_read(p) for p in sorted(SRC.rglob("*.kt"))) + + +def _strip_js_comments(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"(?m)//.*$", "", source) + + +# ── The page comes from the package ────────────────────────────────────────── + +def test_the_interface_is_copied_from_its_single_source_and_never_committed(): + build = _read(APP / "build.gradle.kts") + assert '"../meshbay-hub/src/meshbay_hub/static"' in build + # The desktop application's page: the hub's carries a /a/<hash>/ prefix + # that would point back at the hub. + assert '"../meshbay-client/scripts/index.html"' in build + assert "deleteRecursively()" in build, "a stale file could survive a rebuild" + assert "addGeneratedSourceDirectory" in build + assert "build/" in _read(ANDROID / ".gitignore") + assert not (APP / "src" / "main" / "assets" / "ui").exists(), \ + "a copy of the interface is in the source tree, which is how a fork begins" + + +def test_the_webview_loads_the_package_and_nothing_else(): + activity = _read(SRC / "MainActivity.kt") + loads = re.findall(r"\.loadUrl\(([^)]*)\)", activity) + assert loads and set(loads) == {"UiAssets.START"}, loads + assert "loadDataWithBaseURL" not in activity and "loadData(" not in activity + # The hub never becomes the document origin; a link out leaves the app. + assert "shouldOverrideUrlLoading" in activity and "openExternally" in activity + + +def test_the_policy_is_the_desktop_policy_sent_as_a_header(): + """One interface, one policy for the packaged builds — and the desktop's + is already held to the hub's by test_the_two_policies_stay_in_step.""" + def directives(text: str, start: str, end: str) -> dict[str, str]: + body = text.split(start, 1)[1].split(end, 1)[0] + out = {} + for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): + d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") + out[d.split()[0]] = d + return out + + desktop = directives(_read(CLIENT / "src" / "main.js"), "const CSP = [", "].join") + kotlin = _read(SRC / "shell" / "UiAssets.kt") + android = directives(kotlin, "val CSP = listOf(", ").joinToString") + assert desktop and android == desktop, set(android.items()) ^ set(desktop.items()) + + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '"Content-Security-Policy" to CSP' in assets + recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' + assert recaptcha in assets + markup = re.sub(r"<!--.*?-->", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) + assert "Content-Security-Policy" not in markup + + +def test_the_asset_handler_cannot_be_walked_out_of(): + assets = _read(SRC / "shell" / "UiAssets.kt") + assert '".."' in assets and 'val asset = "ui/"' in assets + + +def test_plain_http_is_refused_except_to_loopback(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "The hub address must be https" in hub + assert 'Regex("^http://(localhost|127\\\\.)")' in hub + config = _read(APP / "src" / "main" / "res" / "xml" / "network_security_config.xml") + assert '<base-config cleartextTrafficPermitted="false"' in config + allowed = re.findall(r"<domain[^>]*>([^<]+)</domain>", config) + assert set(allowed) == {"localhost", "127.0.0.1"} + + +def test_the_page_reaches_the_signed_in_hub_only(): + hub = _read(SRC / "hub" / "HubClient.kt") + assert "Refused: not this hub" in hub and "sameOrigin(target, hub)" in hub + assert ".followRedirects(false)" in hub, "a redirect would carry the token elsewhere" + + +# ── The bridge ────────────────────────────────────────────────────────────── + +def test_no_javascript_interface_is_ever_added(): + """addJavascriptInterface injects into every frame of every origin.""" + for path in APP.rglob("*.kt"): + assert "addJavascriptInterface" not in _read(path), path + + +def test_every_message_is_checked_for_our_top_level_document(): + bridge = _read(SRC / "bridge" / "Bridge.kt") + check = bridge.split("override fun onPostMessage", 1)[1].split("work.execute", 1)[0] + assert "!isMainFrame" in check and "UiAssets.ORIGIN" in check + activity = _read(SRC / "MainActivity.kt") + assert "addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN)" in activity + assert re.search(r"addDocumentStartJavaScript\(web, .*setOf\(UiAssets\.ORIGIN\)\)", activity) + + +def _shim_channels() -> set[str]: + return set(re.findall(r"call\('([\w:-]+)'", _strip_js_comments(_read(SHIM)))) + + +def test_the_shim_offers_desktop_channels_and_native_answers_each(): + preload_js = _read(CLIENT / "src" / "preload.js") + preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) + channels_kt = _read(SRC / "bridge" / "Channels.kt") + native = set(re.findall(r'^\s*"([\w:-]+)" ->', channels_kt, flags=re.M)) + shim = _shim_channels() + assert shim, "no channel found in the shim" + assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + assert shim == native, f"shim and native disagree: {shim ^ native}" + + +def test_what_a_phone_does_not_have_is_absent_not_refusing(): + """platform.js decides what to show from whether an object exists + (`platform.node.available`, `platform.folder.available`, …): an object that + only refused would put screens on the page that fail when used.""" + shim = _strip_js_comments(_read(SHIM)) + exposed = shim.split("const meshbay = {", 1)[1].split("\n };", 1)[0] + for absent in ("node:", "rootPicker:", "minimizeToTray:", "setTrayLabels:"): + assert absent not in exposed, absent + assert "nodeAdmin: false" in exposed and "localFolders: false" in exposed + + +def test_the_bridge_is_frozen_and_the_port_hidden(): + shim = _strip_js_comments(_read(SHIM)) + assert "delete window.meshbayNative" in shim + assert "window.top !== window" in shim + assert "writable: false, configurable: false" in shim + assert "Object.freeze" in shim + + +def test_file_system_access_is_removed_from_the_page(): + """The WebView exposes it (spike S-1) and cannot back it with anything.""" + shim = _strip_js_comments(_read(SHIM)) + for name in ("showDirectoryPicker", "showSaveFilePicker", "showOpenFilePicker"): + assert f"'{name}'" in shim, name + + +def test_the_hub_address_is_injected_not_fetched(): + """platform.hubBase() is called while modules load, before anything can await.""" + assert "HUB_BASE" in _strip_js_comments(_read(SHIM)) + assert "const HUB_BASE = ${JSONObject.quote(hub.base)}" in _read(SRC / "MainActivity.kt") + + +# ── The window ────────────────────────────────────────────────────────────── + +def test_nothing_is_granted_and_video_may_go_fullscreen(): + activity = _read(SRC / "MainActivity.kt") + assert "onPermissionRequest(request: PermissionRequest) = request.deny()" in activity + # Without a custom view, requestFullscreen() never settles (CLAUDE.md). + assert "override fun onShowCustomView" in activity + assert "override fun onHideCustomView" in activity + + +def test_no_backup_carries_the_keys_away(): + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + assert 'android:allowBackup="false"' in manifest + assert 'android:dataExtractionRules="@xml/data_extraction_rules"' in manifest + + +def test_the_gradle_distribution_is_pinned_by_checksum(): + props = _read(ANDROID / "gradle" / "wrapper" / "gradle-wrapper.properties") + assert re.search(r"^distributionSha256Sum=[0-9a-f]{64}$", props, flags=re.M) + + +def test_the_version_is_the_packages_version(): + """All packages share one version (CLAUDE.md); this one reads it rather + than writing it a second time.""" + build = _read(APP / "build.gradle.kts") + assert 'rootDir.resolve("../meshbay-client/package.json")' in build + assert not re.search(r'versionName = "\d', build) + + +@pytest.mark.skipif(not os.environ.get("ANDROID_HOME") or shutil.which("java") is None, + reason="no Android SDK in the environment") +def test_the_jvm_unit_tests_pass(): + result = subprocess.run(["./gradlew", "--no-daemon", "-q", "testDebugUnitTest"], + cwd=ANDROID, capture_output=True, text=True, timeout=900) + assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-3000:] |