Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
3 changes: 2 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false">
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
19 changes: 19 additions & 0 deletions android/app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Arti's directory records which guards this wallet chose. Carrying it to a
second device would carry that choice with it, so a transferred install picks
fresh guards at the cost of one bootstrap.

`allowBackup="false"` already covers cloud backup, but from target SDK 31
device-to-device transfer is governed here instead and defaults to including
app data. `domain="file"` is getFilesDir(), which is what
getApplicationSupportDirectory() resolves to on Android.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="tor" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="tor" />
</device-transfer>
</data-extraction-rules>
40 changes: 40 additions & 0 deletions integration_test/mobile_tor_bootstrap_probe_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:zcash_wallet/src/core/storage/wallet_paths.dart';
import 'package:zcash_wallet/src/rust/api/network_privacy.dart'
as rust_network_privacy;
import 'package:zcash_wallet/src/rust/frb_generated.dart';
import 'package:zcash_wallet/src/rust/network_privacy.dart' as rust_types;

/// Bootstraps Tor from the app's own support directory on a mobile OS.
///
/// A bootstrap failure surfaces from inside fail-closed mode — every request
/// blocked while Tor never becomes ready — so no host test can catch it. This
/// talks to the live Tor network and is not part of any automated lane; run it
/// by hand against a booted simulator or device.
///
/// What it does not settle: whether arti's filesystem permission checks need
/// the mobile exemption. The iOS simulator passes this with the exemption
/// compiled out, because a simulator container is an ordinary directory in the
/// host filesystem. Only a real device answers that.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('Tor bootstraps from the mobile app sandbox', (tester) async {
await RustLib.init();
final torDirectory = await getTorDataDirectoryPath();

rust_network_privacy.beginNetworkPrivacyEnable();
final status = await rust_network_privacy.configureNetworkPrivacy(
enabled: true,
torDirectory: torDirectory,
);

expect(
status,
rust_types.NetworkPrivacyStatus.ready,
reason: 'bootstrap from $torDirectory did not reach ready',
);
expect(rust_network_privacy.isTorEnabled(), isTrue);
}, timeout: const Timeout(Duration(minutes: 5)));
}
56 changes: 56 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FreshInstallKeychainCleaner.runIfNeeded()
// `batteryState` reports `.unknown` until monitoring is on, and the
// background Tor gate reads that state to decide whether this device can
// afford a Tor pass. Enable it here, on the main thread, before any task
// handler can run — a background task launch reaches this method first, so
// the gate finds a real state instead of deferring on `.unknown`.
UIDevice.current.isBatteryMonitoringEnabled = true
BackgroundMigrationManager.shared.registerBackgroundTask()

if #available(iOS 26.0, *) {
Expand Down Expand Up @@ -350,6 +356,56 @@ import UIKit
}
}

let networkPrivacyChannel = FlutterMethodChannel(
name: "com.zcash.wallet/network_privacy",
binaryMessenger: messenger
)
networkPrivacyChannel.setMethodCallHandler { (call, result) in
switch call.method {
case "excludeFromBackup":
// Arti's directory records this wallet's guard choice. Restoring it
// onto another device would carry that choice across, so it is kept
// out of iCloud and finder backups; a restored install picks fresh
// guards at the cost of one bootstrap.
guard
let arguments = call.arguments as? [String: Any],
let path = arguments["path"] as? String,
!path.isEmpty
else {
result(
FlutterError(
code: "invalid_arguments",
message: "excludeFromBackup requires a path.",
details: nil
)
)
return
}
var url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
// Tor was never enabled far enough to create it; nothing to mark.
result(false)
return
}
do {
var values = URLResourceValues()
values.isExcludedFromBackup = true
try url.setResourceValues(values)
result(true)
} catch {
result(
FlutterError(
code: "exclude_failed",
message: error.localizedDescription,
details: nil
)
)
}
default:
result(FlutterMethodNotImplemented)
}
}

let hapticsChannel = FlutterMethodChannel(
name: "com.zcash.wallet/haptics",
binaryMessenger: messenger
Expand Down
57 changes: 56 additions & 1 deletion ios/Runner/BackgroundMigrationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,13 @@ final class BackgroundMigrationManager {
)
let request = BGProcessingTaskRequest(identifier: Self.taskIdentifier)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
// On a Tor route this wake has to bring Tor up before it can reach
// lightwalletd, and a cold bootstrap is 8.45 MB and 23.7 s. Ask the
// scheduler for a charger so it stops waking us for a pass that would
// only decline; on a direct route the wake is cheap and stays
// unconstrained. The other half of the gate — an unmetered link — has no
// equivalent on any request type, so it is checked at run time.
request.requiresExternalPower = BackgroundNetworkRoute.persistedRouteIsTor
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
request.earliestBeginDate = earliestBeginDate
do {
try BGTaskScheduler.shared.submit(request)
Expand Down Expand Up @@ -696,6 +702,19 @@ final class BackgroundMigrationManager {
}

private func handleAuthorized(_ task: BGProcessingTask) {
// This wake may be a cold launch where Dart never applied the saved route,
// and it broadcasts signed transactions. Declare the route before any
// other native call so a path that still reaches lightwalletd fails closed
// rather than putting a transaction on clearnet.
//
// On a Tor route the declaration is only half the answer: the wake also has
// to have established that this device can afford Tor right now. Asking
// here keeps an unaffordable wake from touching the outbox at all, and
// costs nothing — this gate never bootstraps.
guard BackgroundNetworkRoute.allowsBackgroundNetworkPass() else {
finishTorDeferredWake(task)
return
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
}
guard prepareForBackgroundWake() else {
task.setTaskCompleted(success: true)
return
Expand All @@ -709,6 +728,16 @@ final class BackgroundMigrationManager {
task.setTaskCompleted(success: false)
return
}
// Tor before the first query or broadcast. A cold bootstrap blocks for
// tens of seconds, so it runs here on this manager's own queue rather
// than on the notification-gate callback. A client that does not come up
// ready ends the wake the same way an unaffordable one does: fail-closed,
// no network work, the run left to the foreground.
guard BackgroundNetworkRoute.allowsBackgroundNetworkWork() else {
Comment thread
piatoss3612 marked this conversation as resolved.
Comment thread
piatoss3612 marked this conversation as resolved.
self.stopAuthorizationMonitoring()
self.finishTorDeferredWake(task)
return
}
let cancellation = BackgroundMigrationCancellation()
self.stateLock.vizorWithLock {
self.activeCancellation = cancellation
Expand Down Expand Up @@ -815,6 +844,32 @@ final class BackgroundMigrationManager {
}
}

/// Completes a silent wake that must not reach the network: the saved route
/// is Tor and this device cannot carry it right now — on battery, on a
/// metered or constrained link, or with a Tor client that did not come up.
///
/// It queries no chain tip and broadcasts nothing, and it does not
/// reschedule. The foreground is the continuation for a declined wake, as it
/// has been for every Tor route: armed items keep their signed bytes and
/// their schedule, and the foreground outbox run transports them at the next
/// launch — before the item's expiry boundary, or it has to be signed again.
/// The task reports success because declining is the policy working, not a
/// wake that failed.
private func finishTorDeferredWake(_ task: BGProcessingTask) {
// Free of network work, and the one thing this wake can still report: a
// preparation run that needs the foreground for reasons of its own. The
// quiescence and notification fences that gate every other wake apply
// here unchanged.
if #available(iOS 26.0, *),
!isMutationQuiesced,
wakeDisposition.shouldDeliverNotifications
{
BackgroundMigrationPreparationManager.shared
.notifyPreparationNeedsForeground()
}
task.setTaskCompleted(success: true)
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
}

private func clearActiveCancellation() {
stateLock.vizorWithLock {
activeCancellation = nil
Expand Down
Loading