diff --git a/integration_test/android_accessibility/Probe.kt b/integration_test/android_accessibility/Probe.kt new file mode 100644 index 0000000000..90a164c0b4 --- /dev/null +++ b/integration_test/android_accessibility/Probe.kt @@ -0,0 +1,128 @@ +package com.cypherstack.accessibility_probe + +import android.accessibilityservice.AccessibilityService +import android.app.Activity +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.os.Handler +import android.os.Looper +import java.io.File +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import java.util.concurrent.CopyOnWriteArrayList + +abstract class ProbeService : AccessibilityService() { + val events = CopyOnWriteArrayList() + override fun onAccessibilityEvent(event: AccessibilityEvent) { + events.add((event.text + listOf(event.beforeText, event.contentDescription)).joinToString(" ")) + } + override fun onInterrupt() {} + fun tree(): String { + val result = StringBuilder() + fun visit(node: AccessibilityNodeInfo?, depth: Int) { + if (node == null || depth > 50) return + result.append(node.text).append(' ').append(node.contentDescription).append('\n') + for (i in 0 until node.childCount) visit(node.getChild(i), depth + 1) + node.recycle() + } + visit(rootInActiveWindow, 0) + return result.toString() + } +} +class ToolProbeService : ProbeService() { + companion object { @Volatile var instance: ToolProbeService? = null } + override fun onServiceConnected() { instance = this } + override fun onDestroy() { instance = null; super.onDestroy() } +} +class NonToolProbeService : ProbeService() { + companion object { @Volatile var instance: NonToolProbeService? = null } + override fun onServiceConnected() { instance = this } + override fun onDestroy() { instance = null; super.onDestroy() } +} +class ProbeApplication : Application(), Application.ActivityLifecycleCallbacks { + companion object { @Volatile var activity: Activity? = null } + override fun onCreate() { super.onCreate(); registerActivityLifecycleCallbacks(this) } + override fun onActivityCreated(value: Activity, state: Bundle?) { activity = value } + override fun onActivityResumed(value: Activity) { activity = value } + override fun onActivityStarted(value: Activity) {} + override fun onActivityPaused(value: Activity) {} + override fun onActivityStopped(value: Activity) {} + override fun onActivitySaveInstanceState(value: Activity, state: Bundle) {} + override fun onActivityDestroyed(value: Activity) { if (activity === value) activity = null } +} +class ProbeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pending = goAsync() + Thread { + val result = File(context.filesDir, "accessibility-result.txt") + try { + ProbeChecks(intent.getStringExtra("scope") ?: "host").run() + result.writeText("PASS: node queries and text events, before/after activity recreation") + } catch (error: Throwable) { + result.writeText("FAIL: ${error.stackTraceToString()}") + } finally { pending.finish() } + }.start() + } +} +class ProbeChecks(private val scope: String) { + private fun await(message: String, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + 20000 + while (System.currentTimeMillis() < deadline) { + if (predicate()) return + Thread.sleep(100) + } + error(message) + } + fun run() { + await("Both test accessibility services must be enabled") { + ToolProbeService.instance != null && NonToolProbeService.instance != null + } + checkPhase("initial") + val activity = ProbeApplication.activity ?: error("No probe activity") + Handler(Looper.getMainLooper()).post { activity.recreate() } + await("Activity did not recreate") { + ProbeApplication.activity != null && ProbeApplication.activity !== activity + } + checkPhase("recreated") + } + private fun checkPhase(phase: String) { + val tool = ToolProbeService.instance!! + val nonTool = NonToolProbeService.instance!! + val protected = Build.VERSION.SDK_INT >= 34 && scope != "none" + await("$phase: tool cannot read the seed/input") { + val tree = tool.tree() + tree.contains("seed-probe") && tree.contains("private-probe") && tree.contains("public-probe") + } + if (protected) check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) { + "$phase: non-tool received a secret during startup or recreation" + } + tool.events.clear() + nonTool.events.clear() + await("$phase: no positive-control input events for tool") { + tool.events.any { it.contains("private-probe") } + } + repeat(20) { + val tree = nonTool.tree() + if (protected) { + check(!tree.contains("seed-probe") && !tree.contains("private-probe")) { + "$phase: non-tool can query secrets" + } + check(nonTool.events.none { it.contains("private-probe") || it.contains("seed-probe") }) { + "$phase: non-tool received secret events" + } + } else { + check(tree.contains("seed-probe") && tree.contains("private-probe")) { + "$phase: baseline/older-API positive control failed" + } + } + Thread.sleep(100) + } + if (!protected) check(nonTool.events.any { it.contains("private-probe") }) { + "$phase: baseline/older-API event positive control failed" + } + } +} diff --git a/integration_test/android_accessibility/main.dart b/integration_test/android_accessibility/main.dart new file mode 100644 index 0000000000..6b5e8d7081 --- /dev/null +++ b/integration_test/android_accessibility/main.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +void main() => runApp(const MaterialApp(home: Probe())); + +class Probe extends StatefulWidget { + const Probe({super.key}); + @override + State createState() => _ProbeState(); +} + +class _ProbeState extends State { + final controller = TextEditingController(text: 'private-probe-0'); + Timer? timer; + int count = 0; + + @override + void initState() { + super.initState(); + timer = Timer.periodic(const Duration(seconds: 1), (_) { + controller.text = 'private-probe-${++count}'; + }); + } + + @override + void dispose() { + timer?.cancel(); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + const Text('public-probe'), + const Text('seed-probe'), + TextField(controller: controller, autofocus: true), + ], + ), + ); +} diff --git a/integration_test/android_accessibility/run.py b/integration_test/android_accessibility/run.py new file mode 100644 index 0000000000..69e26e663f --- /dev/null +++ b/integration_test/android_accessibility/run.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import argparse +import pathlib +import shutil +import subprocess +import tempfile +import time + +parser = argparse.ArgumentParser() +parser.add_argument('--flutter', default='flutter') +parser.add_argument('--adb', default='adb') +parser.add_argument('--device', required=True) +parser.add_argument('--scope', choices=['host', 'none'], default='host') +parser.add_argument('--work-dir') +args = parser.parse_args() +if not args.device.startswith('emulator-'): + parser.error('Use a disposable emulator; the probe enables test accessibility services.') + +here = pathlib.Path(__file__).resolve().parent +repo = here.parents[1] +work = pathlib.Path(args.work_dir or tempfile.mkdtemp(prefix='stack-a11y-')).resolve() +app = work / 'app' +package = 'com.cypherstack.accessibility_probe' + +def run(command, **kwargs): + return subprocess.run(command, check=True, text=True, **kwargs) + +def adb(*command, capture=False): + return run([args.adb, '-s', args.device, *command], capture_output=capture) + +if not app.exists(): + run([args.flutter, 'create', '--empty', '--platforms=android', '--org', + 'com.cypherstack', '--project-name', 'accessibility_probe', str(app)]) +shutil.copyfile(here / 'main.dart', app / 'lib/main.dart') +activity = (repo / 'scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt').read_text() +activity = activity.replace('package com.place.holder', f'package {package}') +if args.scope == 'none': + start = activity.index(' override fun provideRootLayout') + end = activity.index(' var openPath:', start) + activity = activity[:start] + activity[end:] +kotlin = app / 'android/app/src/main/kotlin/com/cypherstack/accessibility_probe' +kotlin.mkdir(parents=True, exist_ok=True) +(kotlin / 'MainActivity.kt').write_text(activity) +shutil.copyfile(here / 'Probe.kt', kotlin / 'Probe.kt') +xml = app / 'android/app/src/main/res/xml' +xml.mkdir(exist_ok=True) +for name, tool in [('tool', 'true'), ('non_tool', 'false')]: + (xml / f'{name}.xml').write_text(f'''''') +manifest_path = app / 'android/app/src/main/AndroidManifest.xml' +manifest = manifest_path.read_text().replace('${applicationName}', '.ProbeApplication') +start_marker, end_marker = '', '' +while start_marker in manifest: + start = manifest.index(start_marker) + end = manifest.index(end_marker, start) + len(end_marker) + manifest = manifest[:start] + manifest[end:] +services = '' +for cls, xml_name in [('ToolProbeService', 'tool'), ('NonToolProbeService', 'non_tool')]: + services += f''' + + + ''' +services += '' +manifest = manifest.replace('', start_marker + services + end_marker + '') +manifest_path.write_text(manifest) +run([args.flutter, 'build', 'apk', '--debug', '--target-platform', 'android-x64'], cwd=app) +adb('install', '-r', str(app / 'build/app/outputs/flutter-apk/app-debug.apk')) +old_services = adb('shell', 'settings', 'get', 'secure', 'enabled_accessibility_services', capture=True).stdout.strip() +old_enabled = adb('shell', 'settings', 'get', 'secure', 'accessibility_enabled', capture=True).stdout.strip() +try: + services = f'{package}/.ToolProbeService:{package}/.NonToolProbeService' + if old_services and old_services != 'null': + services = old_services + ':' + services + adb('shell', 'settings', 'put', 'secure', 'enabled_accessibility_services', services) + adb('shell', 'settings', 'put', 'secure', 'accessibility_enabled', '1') + adb('shell', 'run-as', package, 'rm', '-f', 'files/accessibility-result.txt') + adb('shell', 'am', 'start', '-n', f'{package}/.MainActivity') + adb('shell', 'am', 'broadcast', '-n', f'{package}/.ProbeReceiver', '--es', 'scope', args.scope) + deadline = time.monotonic() + 90 + output = '' + while time.monotonic() < deadline: + result = subprocess.run([args.adb, '-s', args.device, 'shell', 'run-as', package, + 'cat', 'files/accessibility-result.txt'], text=True, capture_output=True) + if result.returncode == 0 and result.stdout: + output = result.stdout + break + time.sleep(1) + print(output) + if not output.startswith('PASS:'): + raise SystemExit('Accessibility integration check failed or timed out') + +finally: + for key, value in [('enabled_accessibility_services', old_services), ('accessibility_enabled', old_enabled)]: + if value == 'null': + adb('shell', 'settings', 'delete', 'secure', key) + else: + adb('shell', 'settings', 'put', 'secure', key, value) diff --git a/scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt b/scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt index c2ab6b0842..1401c65a2b 100644 --- a/scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt +++ b/scripts/app_config/templates/android/app/src/main/kotlin/com/cypherstack/stackwallet/MainActivity.kt @@ -3,6 +3,7 @@ package com.place.holder import androidx.annotation.NonNull; import io.flutter.embedding.android.FlutterFragmentActivity import android.content.Intent +import android.content.Context import android.os.Bundle import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -13,9 +14,20 @@ import java.nio.charset.Charset import android.os.Build import android.view.ViewTreeObserver import android.view.WindowManager +import android.view.View +import android.widget.FrameLayout class MainActivity: FlutterFragmentActivity() { private val CHANNEL = "STACK_WALLET_RESTORE" + override fun provideRootLayout(context: Context): FrameLayout { + return super.provideRootLayout(context).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // FlutterView inherits this, so only isAccessibilityTool services can read it. + setAccessibilityDataSensitive(View.ACCESSIBILITY_DATA_SENSITIVE_YES) + } + } + } + var openPath: String? = null override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine)