Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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: 3 additions & 0 deletions __mocks__/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
8 changes: 0 additions & 8 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,13 @@ 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"
applicationIdSuffix = ".Beta"
versionCode = 320 // beta override
versionName = "2.0.21" // beta override
resValue("string", "app_name", "Zingo Beta")
resValue("bool", "enforce_privacy_controls", "false")
}
}

Expand Down
38 changes: 10 additions & 28 deletions android/app/src/main/java/org/ZingoLabs/Zingo/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class RPCPackage : ReactPackage {
reactContext: ReactApplicationContext): List<NativeModule> {
val modules: MutableList<NativeModule> = ArrayList()
modules.add(RPCModule(reactContext))
modules.add(ScreenSecurityModule(reactContext))
return modules
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 8 additions & 0 deletions app/LoadingApp/components/NewSeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -67,6 +68,9 @@ const NewSeed: React.FunctionComponent<NewSeedProps> = ({

const clipboardTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

// First view of a freshly created seed — same treatment as Seed.tsx.
const secured = useSecureScreen();

const [texts, setTexts] = useState<TextsType>({} as TextsType);
const [expandSeed, setExpandSeed] = useState<boolean>(true);
const [expandBirthday, setExpandBithday] = useState<boolean>(true);
Expand Down Expand Up @@ -274,6 +278,10 @@ const NewSeed: React.FunctionComponent<NewSeedProps> = ({
[colors, mode, texts, translate],
);

if (!secured) {
return <View style={{ flex: 1, backgroundColor: colors.background }} />;
}

return (
<View
style={{
Expand Down
64 changes: 64 additions & 0 deletions app/hooks/useSecureScreen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { NativeModules, Platform } from 'react-native';

type ScreenSecurityAPI = {
setSecure(secure: boolean): Promise<boolean>;
};

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;
};
7 changes: 6 additions & 1 deletion components/Seed/Seed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,6 +105,10 @@ const Seed: React.FunctionComponent<SeedProps> = ({

const clipboardTimer = useRef<ReturnType<typeof setTimeout> | 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
Expand Down Expand Up @@ -543,7 +548,7 @@ const Seed: React.FunctionComponent<SeedProps> = ({
],
);

if (!authPassed) {
if (!authPassed || !secured) {
return <View style={{ flex: 1, backgroundColor: colors.background }} />;
}

Expand Down
8 changes: 3 additions & 5 deletions ios/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions ios/Zingo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down