diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index adaa817bc..269893d9b 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -54,6 +54,9 @@ jest.mock('react-native', () => { confirmProcess: jest.fn(() => '{}'), getZenniesDonationAddress: jest.fn(() => '{}'), }; + RN.NativeModules.ScreenSecurity = { + setSecure: jest.fn(() => Promise.resolve(true)), + }; RN.View = jest.fn(); RN.RefreshControl = jest.fn(() => null); diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8e5549d7..77e0221d9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -151,13 +151,6 @@ android { create("prod") { dimension = "channel" resValue("string", "app_name", "Zingo") - // Privacy/anti-tamper controls from the Least Authority audit. - // Prod enforces them; beta disables them so testers can take - // screenshots, record video, and so screen-recorder overlays - // don't drop touches. Toggling per-flavor (instead of editing - // MainActivity for releases) keeps prod safe by default — any - // future flavor MUST define this bool or compile will fail. - resValue("bool", "enforce_privacy_controls", "true") } create("beta") { dimension = "channel" @@ -165,7 +158,6 @@ android { versionCode = 320 // beta override versionName = "2.0.21" // beta override resValue("string", "app_name", "Zingo Beta") - resValue("bool", "enforce_privacy_controls", "false") } } diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/MainActivity.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/MainActivity.kt index e78ec072d..cce49c769 100644 --- a/android/app/src/main/java/org/ZingoLabs/Zingo/MainActivity.kt +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/MainActivity.kt @@ -1,11 +1,7 @@ package org.ZingoLabs.Zingo -import android.app.ActivityManager -import android.graphics.Color -import android.os.Build import android.os.Bundle import android.util.Log -import android.view.WindowManager import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -21,31 +17,17 @@ class MainActivity : ReactActivity() { override fun onCreate(savedInstanceState: Bundle?) { Log.i("ON_CREATE", "Starting main activity") - // Gated per-flavor in build.gradle.kts (resValue "enforce_privacy_controls"). - // Prod: true (audit-mandated). Beta: false so testers can capture - // screenshots/video and overlays from screen recorders don't drop touches. - val enforcePrivacyControls = resources.getBoolean(R.bool.enforce_privacy_controls) - if (enforcePrivacyControls) { - // Block screenshots, screen recording, and the recents-screen thumbnail. - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - // Recents card background when FLAG_SECURE blanks the thumbnail. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setTaskDescription( - ActivityManager.TaskDescription.Builder() - .setBackgroundColor(Color.BLACK) - .build() - ) - } - } + // Screenshot blocking is not applied here. FLAG_SECURE used to sit on + // the window for the whole app lifetime, which also killed captures on + // History and Home. It is now toggled per-screen from JS, for the + // screens that render recovery material only — see ScreenSecurityModule + // and app/hooks/useSecureScreen.ts. super.onCreate(null) - if (enforcePrivacyControls) { - // Audit Issue I: tapjacking protection — drop touches if another - // window (e.g. SYSTEM_ALERT_WINDOW overlay) is on top of ours. - window.decorView.filterTouchesWhenObscured = true - } + // Audit Issue I: tapjacking protection — drop touches if another + // window (e.g. SYSTEM_ALERT_WINDOW overlay) is on top of ours. + // Applied to every flavor: beta has to behave exactly like the build + // that ships, or beta testing proves nothing about prod. + window.decorView.filterTouchesWhenObscured = true } override fun createReactActivityDelegate(): ReactActivityDelegate { diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCPackage.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCPackage.kt index 6e6a8c514..5492faabd 100644 --- a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCPackage.kt +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCPackage.kt @@ -15,6 +15,7 @@ class RPCPackage : ReactPackage { reactContext: ReactApplicationContext): List { val modules: MutableList = ArrayList() modules.add(RPCModule(reactContext)) + modules.add(ScreenSecurityModule(reactContext)) return modules } } \ No newline at end of file diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/ScreenSecurityModule.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/ScreenSecurityModule.kt new file mode 100644 index 000000000..96f6649a2 --- /dev/null +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/ScreenSecurityModule.kt @@ -0,0 +1,80 @@ +package org.ZingoLabs.Zingo + +import android.app.Activity +import android.view.WindowManager +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +/** + * Per-screen FLAG_SECURE. Blocks screenshots, screen recording and the + * recents thumbnail while enabled. + * + * The flag belongs to the Activity window, not to a view, so it cannot be + * expressed as a React component. JS owns the lifetime: every setSecure(true) + * needs a matching setSecure(false) once the sensitive screen unmounts. See + * app/hooks/useSecureScreen.ts, which ref-counts the callers. + */ +class ScreenSecurityModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), LifecycleEventListener { + + // A recreated Activity comes back with a fresh window and no flag, while + // JS still holds its ref-count and believes the screen is protected. The + // requested state is kept here and re-applied on every host resume so the + // window can never drift from what JS asked for. + @Volatile private var secureRequested = false + + init { + reactContext.addLifecycleEventListener(this) + } + + override fun getName(): String = "ScreenSecurity" + + override fun invalidate() { + reactContext.removeLifecycleEventListener(this) + super.invalidate() + } + + /** + * Resolves once the request is recorded and applied to the current window. + * Callers await it before rendering anything sensitive. + */ + @ReactMethod + fun setSecure(secure: Boolean, promise: Promise) { + secureRequested = secure + val activity = reactContext.currentActivity + if (activity == null) { + // No window to flag. onHostResume applies the stored state before + // the Activity becomes visible, so nothing capturable exists in + // the meantime and the caller is safe to proceed. + promise.resolve(true) + return + } + activity.runOnUiThread { + apply(activity) + promise.resolve(true) + } + } + + override fun onHostResume() { + val activity = reactContext.currentActivity ?: return + activity.runOnUiThread { apply(activity) } + } + + override fun onHostPause() {} + + override fun onHostDestroy() {} + + private fun apply(activity: Activity) { + if (secureRequested) { + activity.window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } +} diff --git a/app/LoadingApp/components/NewSeed.tsx b/app/LoadingApp/components/NewSeed.tsx index 57fa2f949..4e4e376b2 100644 --- a/app/LoadingApp/components/NewSeed.tsx +++ b/app/LoadingApp/components/NewSeed.tsx @@ -34,6 +34,7 @@ import { import Header from '../../../components/Header'; import Utils from '../../utils'; import { useFullSheetSnapPoints } from '../../hooks/useFullSheetSnapPoints'; +import { useSecureScreen } from '../../hooks/useSecureScreen'; import { showConfirm } from '../../showConfirm'; type TextsType = { @@ -67,6 +68,9 @@ const NewSeed: React.FunctionComponent = ({ const clipboardTimer = useRef | null>(null); + // First view of a freshly created seed — same treatment as Seed.tsx. + const secured = useSecureScreen(); + const [texts, setTexts] = useState({} as TextsType); const [expandSeed, setExpandSeed] = useState(true); const [expandBirthday, setExpandBithday] = useState(true); @@ -274,6 +278,10 @@ const NewSeed: React.FunctionComponent = ({ [colors, mode, texts, translate], ); + if (!secured) { + return ; + } + return ( ; +}; + +const ScreenSecurity = NativeModules.ScreenSecurity as + | ScreenSecurityAPI + | undefined; + +const supported = Platform.OS === 'android' && !!ScreenSecurity; + +// FLAG_SECURE lives on the Activity window, so two secure screens mounted at +// once (Seed pushed over an already-secure screen) have to agree on when it +// comes back off. Last one out clears it. +let holders = 0; + +/** + * Blocks screenshots, screen recording and the recents thumbnail while the + * calling screen is mounted. Android only — iOS has no equivalent flag. + * + * Scoped on purpose: the audit-mandated FLAG_SECURE used to be set once in + * MainActivity.onCreate and never cleared, so History and Home were + * capture-proof too. Only screens that render recovery material need it. + * + * Returns false until the window flag is confirmed applied. Callers render a + * placeholder until then, so no frame containing recovery material can reach + * the compositor before the flag lands. Always true where there is nothing to + * wait for (iOS, or `enabled` false). + */ +export const useSecureScreen = (enabled = true): boolean => { + const [applied, setApplied] = useState(!supported || !enabled); + + useEffect(() => { + if (!enabled || !ScreenSecurity || Platform.OS !== 'android') { + setApplied(true); + return; + } + let live = true; + holders += 1; + // Requested on every mount, not only on the 0 -> 1 edge. The native side + // is idempotent, and a screen mounting on top of an already-secure one + // still needs its own confirmation before it renders. + ScreenSecurity.setSecure(true).then(() => { + if (live) { + setApplied(true); + } + }); + return () => { + live = false; + setApplied(false); + // Clamped: Fast Refresh re-evaluates this module and resets the counter + // to zero under mounted holders. Going negative would strand the window + // secure forever. + holders = Math.max(0, holders - 1); + if (holders === 0) { + ScreenSecurity.setSecure(false); + } + }; + }, [enabled]); + + return applied; +}; diff --git a/components/Seed/Seed.tsx b/components/Seed/Seed.tsx index f8893178d..043616362 100644 --- a/components/Seed/Seed.tsx +++ b/components/Seed/Seed.tsx @@ -39,6 +39,7 @@ import { useFullSheetSnapPoints } from '../../app/hooks/useFullSheetSnapPoints'; import { AppDrawerParamList, ThemeType } from '../../app/types'; import { ContextAppLoaded } from '../../app/context'; import { useBiometricGate } from '../../app/hooks/useBiometricGate'; +import { useSecureScreen } from '../../app/hooks/useSecureScreen'; import { ModeEnum, ChainNameEnum, @@ -104,6 +105,10 @@ const Seed: React.FunctionComponent = ({ const clipboardTimer = useRef | null>(null); + // Seed phrase, birthday and UFVK are on screen here — the only place in + // the app where FLAG_SECURE is warranted. + const secured = useSecureScreen(); + // Audit Issue D — single source of truth for the seed/UFVK biometric // gate. Lives inside Seed.tsx so every navigation path (header, menu, // basic-mode auto-trigger, chain-mismatch recovery, future callers) is @@ -543,7 +548,7 @@ const Seed: React.FunctionComponent = ({ ], ); - if (!authPassed) { + if (!authPassed || !secured) { return ; } diff --git a/ios/SceneDelegate.swift b/ios/SceneDelegate.swift index 1e79a7d0d..0999cc73b 100644 --- a/ios/SceneDelegate.swift +++ b/ios/SceneDelegate.swift @@ -30,13 +30,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // iOS captures the app-switcher snapshot between willResignActive and // didEnterBackground, so the overlay has to be installed here. // - // Gated off in Zingo Beta (ZINGO_BETA compile flag, set in Debug-Beta / - // Release-Beta configurations) so testers can record video and the - // app-switcher snapshot reflects real UI state. Prod always enforces it. + // Applied to every configuration, beta included: beta has to behave + // exactly like the build that ships, or beta testing proves nothing + // about prod. func sceneWillResignActive(_ scene: UIScene) { - #if !ZINGO_BETA showPrivacyOverlay(on: scene) - #endif } func sceneDidBecomeActive(_ scene: UIScene) { diff --git a/ios/Zingo.xcodeproj/project.pbxproj b/ios/Zingo.xcodeproj/project.pbxproj index 2b8670428..2e832dc2d 100644 --- a/ios/Zingo.xcodeproj/project.pbxproj +++ b/ios/Zingo.xcodeproj/project.pbxproj @@ -847,7 +847,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = org.ZingoLabs.Zingo.Beta; PRODUCT_NAME = Zingo; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ZINGO_BETA; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ZINGO_BETA"; SWIFT_OBJC_BRIDGING_HEADER = "RPCModule-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -891,7 +891,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = org.ZingoLabs.Zingo.Beta; PRODUCT_NAME = Zingo; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ZINGO_BETA; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ZINGO_BETA"; SWIFT_OBJC_BRIDGING_HEADER = "RPCModule-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0;