Skip to content

Prevent mnemonic capture in mobile screenshots (VZR-105) - #304

Draft
piatoss3612 wants to merge 12 commits into
mainfrom
rowan/vzr-105
Draft

Prevent mnemonic capture in mobile screenshots (VZR-105)#304
piatoss3612 wants to merge 12 commits into
mainfrom
rowan/vzr-105

Conversation

@piatoss3612

@piatoss3612 piatoss3612 commented Jul 3, 2026

Copy link
Copy Markdown
Member

Problem

On mobile, the seed-phrase (mnemonic) screens only warned about screenshots — they never actually prevented the capture:

  • The warning bottom sheet is triggered by iOS's userDidTakeScreenshotNotification, which fires after the screenshot is already saved. So the mnemonic could already be in the captured image before the user ever sees the warning — a false sense of protection (this is the VZR-105 report).
  • iOS had no pixel-level protection at all. Android had FLAG_SECURE through the shared privacy overlay, but iOS's side of that channel was never wired up.
  • The import screens (paste + word-by-word) rendered the entered mnemonic in plain text with no shield and no warning whatsoever.

Fix

1. Actually blank the capture on iOS, not just warn

Added a native SecureScreenshotShield (in AppDelegate) that hosts the app window inside a secure UITextField's canvas layer. iOS excludes that layer from screenshots, screen recordings, and the app-switcher snapshot, so the captured image comes out blank. The existing privacy_shield MethodChannel is now routed to iOS (it was macOS/Android only).

iOS 26 gotcha: the secure canvas is located by its private view class name, not by sublayer index. The index-based lookup that older implementations use broke on iOS 26.5 — it grabbed a small, offset layer and collapsed the whole app into a corner. After reparenting, the window layer is re-pinned to full bounds on rotation, scene activation, and foreground. The whole mechanism sits behind a kill-switch flag, and the post-capture warning sheet stays as a fallback.

2. Cover the import screens too

The paste and manual import screens now get the same blanking + warning as the reveal screens.

3. Only protect while the secret is actually on screen

Blanking and the warning are gated on the phrase being revealed / words being present. In the hidden states — the "you are about to see your phrase" card, the passcode gate, and the empty import form — screenshots work normally: no black image, no warning, and no app-switcher blur. (On Android this also means the recent-apps thumbnail is only blanked while the words are visible.)

4. Don't flash the privacy blur during the screenshot preview

Taking a screenshot briefly drives the app inactive (the iOS preview/editor covers it), which used to make the Flutter privacy blur flash on and off — pointless noise, since the capture is already blanked. The seed screens now suppress that blur through the screenshot flow and release it on the next foreground return. A genuine app switch still blurs normally.

Testing

Verified on a real iOS 26.5 device (the secure-layer exclusion cannot be reproduced in the Simulator):

  • Revealed seed: screenshot comes out black, screen recording black, app-switcher snapshot blank.
  • Hidden states (warning card / passcode gate / empty import form): screenshots are normal, no warning sheet.
  • Screenshot preview open → close: no blur flash.
  • Full-size rendering confirmed, including rotation and background → foreground.

fvm flutter analyze is clean; the privacy-overlay and mobile seed/import widget tests pass.

Notes

  • No new dependency. The technique is ported in-repo (≈ the open-source no_screenshot approach). screen_protector's closed-source binary path was deliberately avoided — an unauditable binary reparenting the key window on the seed-reveal screen is not acceptable supply-chain surface for a wallet.
  • The iOS secure-layer trick relies on undocumented UIKit internals. It's behind a flag and degrades to the warning sheet if a future iOS breaks it, so re-verify on a real device each iOS major. The Simulator cannot validate the blanking — real-device QA is the merge gate.

The iOS seed screens only listened for the system screenshot notification, which fires after the shot is saved, so the mnemonic could still land in the image. Route the existing privacy_shield channel to iOS (previously macOS/Android only) and add SecureScreenshotShield, which hosts the app window inside a secure UITextField canvas layer that iOS omits from screenshots, screen recordings, and the app switcher.

Identify that canvas by its private view class name rather than sublayer index — the index moved on iOS 26 and collapsed the app into a corner — and re-pin the reparented window layer to full bounds on rotation, scene activation, and foreground. A kill-switch flag and the post-capture warning sheet stay as fallbacks.

Also keep the Flutter privacy blur down through the iOS screenshot preview/editor: taking a screenshot briefly drives the app inactive, which would otherwise flash the blur even though the capture is already blanked. The seed screens mark a short suppression window when a screenshot fires and release it on the next foreground return.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
The paste and manual import screens showed the entered mnemonic with no shield and no screenshot warning. Wrap them like the reveal screens — blanking and the warning sheet gated on words actually being on screen — so pasted and typed words get the same protection.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
@piatoss3612 piatoss3612 self-assigned this Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04752de1a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/core/privacy/sensitive_privacy_overlay.dart Outdated
The suppression used an 8s timer that treated any inactive transition as safe, so backgrounding the app (app switcher / lock) within that window kept the privacy blur down and leaned on the native trick alone. Tie it to the warning sheet's lifetime instead — begin when the sheet shows, release when it is dismissed — so a genuine backgrounding once the sheet is gone blurs normally, while the screenshot preview/editor is still covered.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3faadcb669

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/features/onboarding/mobile/mobile_import_screens.dart Outdated
SensitivePrivacyOverlay drives a global native shield token (Android FLAG_SECURE, iOS secure-field blanking), so a mnemonic screen that pushed the next step on top stayed mounted with its content flag set and kept blanking the pushed, non-secret screens (e.g. the birthday-height step captured black). Drop the token before pushing the next step and restore it if the user comes back, on the paste import, manual import, and create-reveal screens.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc51333475

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/features/onboarding/mobile/mobile_import_screens.dart Outdated
isLayerAttached latched to the first window and never reset. Because the app is UIScene-based, a scene reconnect (e.g. after a memory-pressure disconnect) hands the app a fresh UIWindow, leaving the shield grafted onto the dead one; the secret screen would then capture in plaintext with no error and no fallback. Re-graft when the shielded window is gone or is no longer the key window, and tear down the geometry observers on re-attach.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
The offstage-drop previously flipped the global shield token synchronously before context.push, so the outgoing secret screen slid away for ~350ms with no blanking — a screenshot or active recording during that window captured the mnemonic. Drive the drop off the route's secondaryAnimation (RouteCoverageAware) instead: the shield stays engaged through the entire push slide-out and pop slide-in and drops only once the next screen has fully covered this one. Replaces the _coveredByPush push/pop-Future flag on the paste, manual, and create-reveal screens; also covers the Enter-manually push.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
beginAuthPrompt/endAuthPrompt lacked the _disposed guard their screenshot-suppression siblings have. endAuthPrompt runs from a post-await finally, so a mid-await dispose could call notifyListeners on a disposed controller. Debug/profile-only (the assert is compiled out of release), but the one-line guard mirrors the siblings.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98d055b36f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/core/privacy/route_coverage_aware.dart
Regression guard for the RouteCoverageAware mixin: the warning sheet is a non-opaque popup (showModalBottomSheet), and a PageRoute does not drive its secondaryAnimation for a popup pushed above it, so isCoveredByNextRoute stays false and the shield remains engaged — a second screenshot or app-switcher snapshot while the sheet is open is still blanked. Pins that behavior so a future change to the coverage signal cannot silently drop the shield behind a popup.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 211b2bfd55

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/core/privacy/route_coverage_aware.dart
Replaces the MaterialApp-based guard with one on the real go_router (CupertinoPage) routing and asserts the seed route's secondaryAnimation stays AnimationStatus.dismissed while the warning bottom sheet is open — so RouteCoverageAware never marks the screen covered behind a non-opaque popup, and the shield (native token + blur) stays engaged for a second screenshot or app-switcher snapshot.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36024480c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/src/features/onboarding/mobile/mobile_import_manual_screen.dart
screenshotEvents() called receiveBroadcastStream() per listener. When the paste import screen pushes /import/manual (both mounted with an invalid/short paste), the second subscription re-runs the single-listener native ScreenshotStreamHandler, and popping manual tears the handler down — so returning to the still-mounted paste screen with words visible no longer receives screenshot events (the warning sheet stops firing; native blanking is unaffected). Cache the broadcast stream so all listeners share one native subscription that stays alive until the last one cancels.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 438127f298

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ios/Runner/AppDelegate.swift Outdated
The prior fix reset the stale-window latch inside attachLayerIfNeeded(), but that runs only from setSensitiveContentVisible(), which Dart sends only when the token set changes. After a scene reconnect swaps in a fresh UIWindow while a secret is still shown, the token set is unchanged so Dart never re-triggers, and the activation observers only re-pinned geometry (a no-op once shieldedWindow is gone) — leaving the new window outside the secure canvas and the phrase capturable. Have the activation/rotation observers re-graft to the current key window before reasserting geometry.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
The suppression treated any inactive transition as safe while the warning sheet was open, so the app-switcher/lock snapshot (taken at inactive) was left unblurred, and its stuck-state interactions could drop the privacy blur on unrelated focus losses (found in on-device testing). Its only benefit was avoiding a brief blur flash when the iOS screenshot markup editor is opened and closed — a cosmetic artifact in an uncommon flow, and the actual screenshot is already blacked out by the native shield. Drop the suppression so the blur always covers the app on focus loss; keep the native blanking (screenshots/recordings) and the warning sheet.

Claude-Session: https://claude.ai/code/session_01KpMXnF65A75T1rLxmrcJRm
@piatoss3612
piatoss3612 marked this pull request as draft July 6, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant