Skip to content
Open
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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ jobs:
- run: bun run lint
- run: bun run typecheck
- run: bun run test
# No-device regression gate for the emulator driver's content-aware
# sanitizer + screenshot leak gate. Deliberately INSIDE the verify
# job (not the optional debug-emulator workflow) so a PR cannot
# turn green while the BIP-39 / RecoveryPhrase redaction path is
# broken. The self-test runs locally with no APK / emulator
# required and exits non-zero on any sanitizer parity failure.
- run: bun run emulator:self-test

build-android:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -85,6 +92,6 @@ jobs:
-scheme EnboxMobile \
-configuration Debug \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
working-directory: ios
372 changes: 333 additions & 39 deletions .github/workflows/debug-emulator.yml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ local.properties

# node.js
#
node_modules
node_modules/
npm-debug.log
yarn-error.log
Expand Down Expand Up @@ -66,6 +67,13 @@ yarn-error.log
# testing
/coverage

# Agent/planning scratch space
.factory/

# Python scratch (scripts/ contains Python helpers for CI emulator flow)
__pycache__/
*.pyc

# Yarn
.yarn/*
!.yarn/patches
Expand Down
5 changes: 5 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")

// Biometric vault: androidx.biometric provides BiometricPrompt /
// BiometricManager used by NativeBiometricVaultModule. Version is
// pinned (not "+") per the mission contract (VAL-NATIVE-020).
implementation("androidx.biometric:biometric:1.1.0")

if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />

<application
android:name=".MainApplication"
Expand Down
34 changes: 34 additions & 0 deletions android/app/src/main/java/org/enbox/mobile/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.enbox.mobile

import android.os.Bundle
import android.view.WindowManager
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
Expand All @@ -13,6 +15,38 @@ class MainActivity : ReactActivity() {
*/
override fun getMainComponentName(): String = "EnboxMobile"

/**
* Activity-level FLAG_SECURE baseline (VAL-UX-043).
*
* The per-screen FlagSecure module (see `FlagSecureModule.kt`) applies
* `FLAG_SECURE` asynchronously through `runOnUiThread` when a sensitive
* screen such as `RecoveryPhraseScreen` mounts. That post-to-UI-thread
* hop creates a first-frame window where the mnemonic can be
* captured — by a screenshot, the Recents thumbnail, or screen-mirroring
* software — before the flag lands. Setting the flag here, BEFORE the
* React root view is attached, closes that window: every frame the user
* ever sees (including splash and JS-bundle-load) is already marked
* secure by the WindowManager, so no framebuffer snapshot of the app is
* ever exposed to the system.
*
* This baseline is paired with the FlagSecureModule's reference-
* counted activate/deactivate counter (initialised to 1 to mirror the
* baseline). A per-screen `activate→deactivate` cycle therefore
* leaves the counter at 1 and the flag SET — the baseline survives
* sensitive-screen unmounts, so the first-frame race cannot
* reappear when a SECOND sensitive screen mounts later in the
* session. Without that refcount, the unmount path's unconditional
* `clearFlags` would tear down this baseline, defeating the
* first-frame guarantee.
*/
override fun onCreate(savedInstanceState: Bundle?) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
super.onCreate(savedInstanceState)
}

/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package org.enbox.mobile.nativemodules

import android.view.WindowManager.LayoutParams.FLAG_SECURE
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.util.concurrent.atomic.AtomicInteger

/**
* FlagSecureModule — Android native bridge for toggling
* `WindowManager.LayoutParams.FLAG_SECURE` on the host Activity.
*
* When active, FLAG_SECURE blocks:
* - screenshots (adb / hardware key combos)
* - screen recording
* - the thumbnail shown in the app Recents / task-switcher list
*
* JS contract (see `src/lib/native/flag-secure.ts`):
* - Canonical NativeModules name is `EnboxFlagSecure`.
* - `activate(promise)` sets FLAG_SECURE on the current Activity's window.
* - `deactivate(promise)` clears it.
* - Both methods resolve `null` once the flag change has been scheduled
* on the UI thread (window flag mutations MUST happen on the UI thread
* per Android SDK contract); they never reject with an error so the JS
* shim can keep a best-effort, silently-no-op posture.
*
* Reference-counted activation (VAL-UX-043):
*
* `MainActivity.onCreate` sets FLAG_SECURE before the first frame as a
* global baseline. Sensitive screens (RecoveryPhraseScreen,
* RecoveryRestoreScreen) call `activate()` on mount and `deactivate()`
* on unmount, expecting the flag to ride alongside the screen lifecycle.
* Without a refcount the unmount path's unconditional `clearFlags`
* would tear down the MainActivity baseline too — re-opening the
* first-frame race that the baseline exists to prevent on EVERY
* subsequent sensitive-screen mount, since the activate→deactivate
* pair only re-asserts the flag for the duration of the sensitive
* screen.
*
* The counter starts at 1 to mirror the `MainActivity.onCreate`
* baseline. `activate()` increments and reapplies FLAG_SECURE;
* `deactivate()` decrements, and only count 0 clears the flag. The
* count is clamped at 0 so a stray extra `deactivate()` is a no-op
* rather than
* a negative refcount. With the baseline of 1, a single per-
* screen `activate→deactivate` pair leaves the baseline
* (count == 1) intact.
*
* `AtomicInteger` is used because `activate`/`deactivate` may be
* invoked from different RN bridge threads concurrently with
* `runOnUiThread`-scheduled window mutations; the counter itself
* doesn't drive the actual window state (the UI-thread block
* handles that), it only records the desired final state.
*
* Deliberately NOT a TurboModule (no codegen spec) — the surface is tiny,
* platform-specific (Android only), and does not need TurboModule eager
* loading. The JS shim probes `NativeModules.EnboxFlagSecure` lazily.
*
* See VAL-UX-043 in `validation-contract.md`.
*/
class FlagSecureModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {

companion object {
/**
* Canonical module name exposed to JS. Must match the name probed
* by `src/lib/native/flag-secure.ts`.
*/
const val NAME = "EnboxFlagSecure"

/**
* Process-wide refcount of FLAG_SECURE activations. Initialised
* to 1 to mirror the activity-level baseline applied in
* `MainActivity.onCreate`. `companion object` scope (not
* instance scope) so a React context restart that recreates
* the FlagSecureModule does not zero the counter and clear the
* baseline that was applied by the activity outside this
* module's lifetime.
*
* Internal visibility for the test harness — production
* callers must go through `activate()` / `deactivate()`.
*/
@JvmStatic
internal val activationCount: AtomicInteger = AtomicInteger(1)
}

override fun getName(): String = NAME

@ReactMethod
fun activate(promise: Promise) {
// FlagSecureModule extends ReactContextBaseJavaModule directly (not
// a codegen spec), so `currentActivity` is NOT resolvable as a bare
// identifier under Kotlin compilation on CI. Go through
// `reactApplicationContext.currentActivity` which returns a
// nullable Activity? — mirrors the pattern used by upstream RN Java
// samples and keeps our silently-no-op contract for a detached
// Activity (e.g. headless bring-up).
activationCount.incrementAndGet()
val activity = reactApplicationContext.currentActivity ?: run {
promise.resolve(null)
return
}
activity.runOnUiThread {
activity.window?.setFlags(FLAG_SECURE, FLAG_SECURE)
}
promise.resolve(null)
}

@ReactMethod
fun deactivate(promise: Promise) {
// Decrement, clamped at 0 so a stray extra `deactivate()` doesn't
// drive the counter negative. Only clear FLAG_SECURE when the
// refcount reaches 0 — i.e. neither the MainActivity baseline
// (count >= 1 always while the baseline holds) nor any sensitive
// screen still wants the flag on. With the baseline initialised
// to 1, a single sensitive-screen activate→deactivate pair leaves
// the count at 1 and the flag stays SET, preserving the
// first-frame-secure guarantee for any subsequent sensitive
// screen that mounts later in the session.
val newCount = activationCount.updateAndGet { current ->
if (current <= 0) 0 else current - 1
}
val activity = reactApplicationContext.currentActivity ?: run {
promise.resolve(null)
return
}
if (newCount == 0) {
activity.runOnUiThread {
activity.window?.clearFlags(FLAG_SECURE)
}
}
promise.resolve(null)
}
}
Loading
Loading