Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions ci/android_exported_components.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Pre-audit item 16: no component a release build declares may be started by
# another app except the launcher activity. An exported component is an entry
# point any installed app can call with intents and extras of its choosing; a
# component that can do something irreversible or security-sensitive must not
# be one. Debug-only tools are declared in src/debug/AndroidManifest.xml, which
# a release build does not merge.
set -euo pipefail
echo "=== android: the release manifest exports the launcher only ==="

manifest=dsm_client/android/app/src/main/AndroidManifest.xml
[[ -f "$manifest" ]] || { echo "[FAIL] $manifest is missing"; exit 1; }

python3 - "$manifest" <<'PY'
import sys
import xml.etree.ElementTree as ET

ANDROID = "{http://schemas.android.com/apk/res/android}"
LAUNCHER = "com.dsm.wallet.ui.MainActivity"
manifest = ET.parse(sys.argv[1]).getroot()
application = manifest.find("application")
problems = []
for kind in ("activity", "activity-alias", "service", "receiver", "provider"):
for component in application.findall(kind):
name = component.get(ANDROID + "name", "")
exported = component.get(ANDROID + "exported")
filters = component.findall("intent-filter")
if exported is None:
problems.append(f"{kind} {name} does not state android:exported")
continue
if exported == "true" and name != LAUNCHER:
problems.append(f"{kind} {name} is exported")
if name == LAUNCHER:
actions = {a.get(ANDROID + "name") for f in filters for a in f.findall("action")}
if actions != {"android.intent.action.MAIN"}:
problems.append(f"the launcher answers {sorted(actions)}, not MAIN alone")
for f in filters:
if f.findall("data"):
problems.append("the launcher declares a data filter (a deep link)")
for problem in problems:
print(f"[FAIL] {problem}")
if problems:
sys.exit(1)
print(" ✓ only the launcher is exported, and it answers MAIN alone")
PY
1 change: 1 addition & 0 deletions ci/production_safety_checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ echo ""
# still owes the binding from that operation to the accepted owner transition.
bash ci/sofi_genesis_acceptance_binding.sh
bash ci/sofi_relay_is_party_neutral.sh
bash ci/android_exported_components.sh

# Only an ordinary single-root lineage can become an eligible peer debit
# (P15-9). The discriminant is worthless if a caller can attach it, and
Expand Down
25 changes: 25 additions & 0 deletions dsm_client/android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug builds only. Merged into the main manifest for the debug variant; a release build
declares none of this (pre-audit item 16). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- H2 bench self-test: launched when the Pico anchor attaches over USB-OTG (that launch
also grants USB permission for the device). Replays one OP_SPI_PASSTHROUGH round-trip
and logs whether the phone reached the real TROPIC01. Debug bring-up only: it is
exported, and a launch carrying its confirmation extras runs irreversible chip
operations (counter init, birth cage), so it is declared for debug builds alone. -->
<activity
android:name="com.dsm.wallet.debug.PicoSelfTestActivity"
android:exported="true"
android:label="Pico USB self-test"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/pico_device_filter" />
</activity>
</application>
</manifest>
18 changes: 3 additions & 15 deletions dsm_client/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,9 @@
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />

<!-- H2 bench self-test: launched when the Pico anchor attaches over USB-OTG (that launch
also grants USB permission for the device). Replays one OP_SPI_PASSTHROUGH round-trip
and logs whether the phone reached the real TROPIC01. Debug bring-up only. -->
<activity
android:name="com.dsm.wallet.debug.PicoSelfTestActivity"
android:exported="true"
android:label="Pico USB self-test"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/pico_device_filter" />
</activity>
<!-- The H2 bench self-test (PicoSelfTestActivity) is declared in src/debug/AndroidManifest.xml
only: it is exported, and a launch with its confirmation extras runs irreversible chip
operations, so a release build must not declare it (pre-audit item 16). -->

<!-- Hardware Incompatibility Screen (shown when JNI library fails to load) -->
<activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package com.dsm.wallet.ui

import android.Manifest
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
Expand Down Expand Up @@ -1727,6 +1728,23 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
}
}

/**
* A page asked to leave the app. Only the beta issue form goes, to the
* system browser; every other address is refused (pre-audit item 13).
*/
private fun openOutside(uri: Uri) {
when (WebNavigationPolicy.decide(uri.scheme, uri.host, uri.path)) {
WebNavigation.IssueForm ->
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
Log.w(tag, "No browser to open the issue form in", e)
}
WebNavigation.App, WebNavigation.NativeQr, WebNavigation.Refused ->
Log.w(tag, "Refused to open ${uri.scheme}://${uri.host} outside the app")
}
}

@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView(wv: WebView) {
assetLoader = WebViewAssetLoader.Builder()
Expand Down Expand Up @@ -1776,21 +1794,14 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
val hitResult = view?.hitTestResult
val url = hitResult?.extra
if (!url.isNullOrEmpty()) {
Log.i(tag, "window.open intercepted — opening in system browser: $url")
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
openOutside(Uri.parse(url))
return false
}
// Secondary path: create a temporary WebView to capture the URL
val tempWebView = WebView(view?.context ?: this@MainActivity)
tempWebView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView?, request: WebResourceRequest?): Boolean {
val uri = request?.url
if (uri != null) {
Log.i(tag, "window.open secondary path: opening in system browser: ${uri.host}")
val intent = Intent(Intent.ACTION_VIEW, uri)
startActivity(intent)
}
request?.url?.let { openOutside(it) }
tempWebView.destroy()
return true
}
Expand Down Expand Up @@ -1874,7 +1885,7 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
try {
if (WebViewFeature.isFeatureSupported(WebViewFeature.POST_WEB_MESSAGE)) {
val msg = WebMessageCompat("", arrayOf(port))
WebViewCompat.postWebMessage(target, msg, "https://appassets.androidplatform.net".toUri())
WebViewCompat.postWebMessage(target, msg, WebNavigationPolicy.APP_ORIGIN.toUri())
pendingJsPort = null
Log.i(tag, "Delivered DSM MessagePort to page")
}
Expand Down Expand Up @@ -1908,31 +1919,25 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback {
return assetLoader.shouldInterceptRequest(uri)
}

// Every navigation goes through WebNavigationPolicy (pre-audit item 13):
// only the app's own page loads here; the QR link and the issue form
// are handled natively; everything else is refused.
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
try {
val uri = request?.url ?: return false
// Handle native DSM deep links
if (uri.scheme == "dsm" && uri.host == "native") {
val path = uri.path ?: ""
if (path == "/qr/start") {
Log.i(tag, "WebView requested native QR scan")
launchNativeQrScanner { qrText: String? ->
dispatchQrScanResult(qrText)
}
return true
val uri = request?.url ?: return super.shouldOverrideUrlLoading(view, request)
val navigation = WebNavigationPolicy.decide(uri.scheme, uri.host, uri.path)
when (navigation) {
WebNavigation.App -> Unit
WebNavigation.NativeQr -> {
Log.i(tag, "WebView requested native QR scan")
launchNativeQrScanner { qrText: String? ->
dispatchQrScanResult(qrText)
}
}
// External URLs (http/https): open in system browser, keep WebView intact
if (uri.scheme == "http" || uri.scheme == "https") {
Log.i(tag, "Opening external URL in system browser: ${uri.host}")
val intent = Intent(Intent.ACTION_VIEW, uri)
startActivity(intent)
return true
}
} catch (t: Throwable) {
Log.w(tag, "shouldOverrideUrlLoading: error", t)
WebNavigation.IssueForm -> openOutside(uri)
WebNavigation.Refused ->
Log.w(tag, "Refused navigation to ${uri.scheme}://${uri.host}")
}
return false
return navigation != WebNavigation.App
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: Apache-2.0
package com.dsm.wallet.ui

/** Where a navigation the page asks for may go. */
enum class WebNavigation {
/** The app's own packaged page: it loads in the WebView. */
App,

/** The page's one link to the native QR scanner. */
NativeQr,

/** The release repository's new-issue form, opened in the system browser. */
IssueForm,

/** Anything else: never loaded in the WebView, never handed to another app. */
Refused,
}

/**
* The WebView's navigation policy (pre-audit item 13). The page is the app's
* packaged origin and nothing else: it loads only its own assets, asks for the
* native QR scanner by its one link, and sends the user to one outside page,
* the beta bug and feedback form. Every other address is refused: another
* site, the app's origin outside its assets, and every other scheme (`file:`,
* `content:`, `intent:`, `javascript:`, `data:`, `http:`), so an injected link
* can neither replace the page nor launch another app with the page's data.
*/
object WebNavigationPolicy {
const val APP_ORIGIN = "https://appassets.androidplatform.net"
private const val APP_HOST = "appassets.androidplatform.net"
private const val APP_PATH_PREFIX = "/assets/"
private const val ISSUE_FORM_HOST = "github.com"
private const val ISSUE_FORM_PATH = "/deterministicstatemachine/dsm/issues/new"

/** The navigation to `scheme://host/path`, as `android.net.Uri` splits it. */
fun decide(scheme: String?, host: String?, path: String?): WebNavigation {
val s = scheme?.lowercase()
val h = host?.lowercase()
return when {
s == "https" && h == APP_HOST && path.orEmpty().startsWith(APP_PATH_PREFIX) ->
WebNavigation.App
s == "dsm" && h == "native" && path == "/qr/start" -> WebNavigation.NativeQr
s == "https" && h == ISSUE_FORM_HOST && path == ISSUE_FORM_PATH -> WebNavigation.IssueForm
else -> WebNavigation.Refused
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: Apache-2.0
package com.dsm.wallet.ui

import org.junit.Assert.assertEquals
import org.junit.Test

/**
* Pre-audit item 13: the WebView loads only the app's own packaged page,
* reaches the native QR scanner by its one link, and opens only the beta
* issue form outside the app. Everything else is refused.
*/
class WebNavigationPolicyTest {
private fun decide(scheme: String?, host: String?, path: String?) =
WebNavigationPolicy.decide(scheme, host, path)

@Test
fun the_apps_own_page_loads_in_the_webview() {
assertEquals(WebNavigation.App, decide("https", "appassets.androidplatform.net", "/assets/index.html"))
assertEquals(WebNavigation.App, decide("HTTPS", "AppAssets.androidplatform.net", "/assets/js/main.js"))
}

@Test
fun the_qr_link_and_the_issue_form_are_the_only_ways_out() {
assertEquals(WebNavigation.NativeQr, decide("dsm", "native", "/qr/start"))
assertEquals(
WebNavigation.IssueForm,
decide("https", "github.com", "/deterministicstatemachine/dsm/issues/new"),
)
}

@Test
fun every_other_address_is_refused() {
val refused = listOf(
Triple("https", "appassets.androidplatform.net", "/res/raw/secret"),
Triple("http", "appassets.androidplatform.net", "/assets/index.html"),
Triple("https", "evil.example", "/assets/index.html"),
Triple("https", "github.com", "/deterministicstatemachine/dsm/issues"),
Triple("https", "github.com", "/someone-else/dsm/issues/new"),
Triple("https", "github.com.evil.example", "/deterministicstatemachine/dsm/issues/new"),
Triple("http", "github.com", "/deterministicstatemachine/dsm/issues/new"),
Triple("dsm", "native", "/wallet/send"),
Triple("file", null, "/data/data/com.dsm.wallet/databases/dsm_client.db"),
Triple("content", "com.dsm.wallet.provider", "/anything"),
Triple("intent", null, null),
Triple("javascript", null, null),
Triple("data", null, null),
Triple(null, null, null),
)
for ((scheme, host, path) in refused) {
assertEquals("$scheme://$host$path", WebNavigation.Refused, decide(scheme, host, path))
}
}
}
Loading
Loading