Skip to content

fix(wallet/recovery): require manual reveal before showing seed phrase (#2657)#2661

Closed
oxoxDev wants to merge 2 commits into
tinyhumansai:mainfrom
oxoxDev:fix/2657-recovery-phrase-blur
Closed

fix(wallet/recovery): require manual reveal before showing seed phrase (#2657)#2661
oxoxDev wants to merge 2 commits into
tinyhumansai:mainfrom
oxoxDev:fix/2657-recovery-phrase-blur

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented May 26, 2026

Summary

  • Hide the freshly-generated 12-word recovery phrase behind a blur + eye-slash overlay so it isn't visible at-a-glance to anyone next to the user when they open Settings → Account → Recovery Phrase.
  • One-way reveal: clicking the overlay un-blurs the grid for the rest of the session; switching modes re-arms the gate.
  • Disable Copy to Clipboard while the phrase is hidden (visual + disabled attr + aria-disabled + sync guard inside handleCopy for AT bypass).

Problem

When the user navigates to Settings → Account → Recovery Phrase, the 12-word seed is rendered in plain text immediately. Anyone standing next to the user can read or photograph it before the user has any chance to hide it or move to a private location. This is a small but real shoulder-surfing exposure on a high-blast-radius secret (full wallet recovery).

Solution

  • New revealed boolean state in RecoveryPhrasePanel, default false.
  • Word grid wrapped in a container that applies blur-md, pointer-events-none, select-none, and aria-hidden="true" while not revealed — words can't be highlighted, copied via context menu, or focused.
  • An eye-slash overlay button sits over the grid in the hidden state, labelled with the existing mnemonic.reveal i18n key (no new strings needed; mnemonic.reveal / mnemonic.hidden were already in en.ts and all locale chunks).
  • Copy to Clipboard button is disabled, aria-disabled="true", gets opacity-50 cursor-not-allowed, and has a hover-title using mnemonic.hidden while hidden.
  • handleCopy also short-circuits if revealed === false — defence in depth in case AT or scripted activation bypasses the disabled attribute.
  • switchMode resets revealed to false so a re-entry through import → generate re-arms the gate.

Submission Checklist

If a section does not apply to this change, mark the item as N/A with a one-line reason. Do not delete items.

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy — 5 new Vitest cases under RecoveryPhrasePanel — manual reveal gate (#2657) covering hidden-default, copy-disabled, reveal-click, clipboard write only after reveal, mode-switch re-arms.
  • Diff coverage ≥ 80% — Vitest covers every changed branch in RecoveryPhrasePanel.tsx: the revealed state, the conditional grid classes, the overlay button, the disabled-copy path, the handleCopy guard, and the switchMode reset. 11 / 11 tests pass on pnpm debug unit RecoveryPhrasePanel.
  • N/A: Coverage matrix updated — behaviour-only UI hardening of an existing surface; no new feature row to add to docs/TEST-COVERAGE-MATRIX.md.
  • N/A: All affected feature IDs from the matrix are listed in ## Related — same reason as above.
  • No new external network dependencies introduced — single-file FE change.
  • N/A: Manual smoke checklist updated if this touches release-cut surfaces — Settings panel polish; no release-cut surface added.
  • Linked issue closed via Closes #NNN in the ## Related section — see below.

Impact

  • Security: lowers shoulder-surfing exposure on the recovery-phrase surface. Phrase is never visible before user gesture; the copy path is gated the same way.
  • Behaviour: existing flows unchanged after the user clicks reveal. Consent checkbox + Save button keep their current semantics. No new IPC, no new RPC, no new storage.
  • Performance: nil. One extra piece of React state + a CSS class toggle.
  • Compatibility: no migrations. mnemonic.reveal and mnemonic.hidden i18n keys already shipped in every locale chunk — zero translator work needed.

Related

  • Closes: Recovery phrase should require manual reveal #2657
  • Follow-up PR(s)/TODOs:
    • Consider adding the same blur-on-default treatment to any future surface that renders a private key, encryption key, or API token in cleartext (not in scope for this PR).

AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: fix/2657-recovery-phrase-blur
  • Commit SHA: 6578565e (tip; 2 commits in the series)

Validation Run

  • pnpm --filter openhuman-app format:check — clean (prettier --check + rust:format:check both pass).
  • pnpm typecheck — clean (tsc --noEmit, 0 errors).
  • Focused tests: pnpm debug unit RecoveryPhrasePanel11 passed / 0 failed, including 5 new manual reveal gate (#2657) cases.
  • Rust fmt/check: N/A (no Rust files touched).
  • Tauri fmt/check: N/A (no Tauri files touched).

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: recovery phrase is hidden behind a one-way reveal gate on entry to Settings → Account → Recovery Phrase. Copy to Clipboard is disabled while hidden.
  • User-visible effect: users see an eye-slash overlay over the 12-word grid until they click it; after the click, the words and Copy button behave exactly as before.

Parity Contract

  • Legacy behavior preserved: yes for every post-reveal path. Generate / Import mode flows, consent checkbox, Save button, success state, and import-mode UI are all untouched. The pre-existing trust-surface tests (amber callout, palette token, mode-switch reset) still pass.
  • Guard/fallback/dispatch parity checks: the new if (!revealed) return; in handleCopy mirrors the standard "tool guard before action" pattern; reviewers can diff this against any other ReadOnly guard in the codebase for visual parity.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

  • New Features
    • Added a reveal gate for recovery phrases—the mnemonic is hidden by default and requires clicking a reveal button to display it.
    • Copy-to-clipboard functionality is disabled until the phrase is revealed.
    • Recovery phrase automatically returns to hidden state when switching between generate and import modes.

Review Change Stack

oxoxDev added 2 commits May 26, 2026 10:28
…se (tinyhumansai#2657)

Blur the 12-word grid by default and disable copy until the user clicks
an eye-slash overlay. Pointer-events and user-select are blocked on the
hidden grid so the words can't be highlighted or copied via context menu
before reveal. The reveal gesture is one-way per session — switching
modes re-arms the gate.

Defense in depth: `handleCopy` short-circuits if `revealed === false`
even if the disabled attribute is bypassed (some AT tooling can route
around `disabled`).
Five new Vitest cases covering the manual-reveal contract:
- mnemonic grid is blurred + aria-hidden by default
- Copy to Clipboard is disabled while phrase is hidden
- click on reveal overlay un-blurs and enables Copy (one-way)
- writeText is only called after reveal
- mode switch re-arms the gate

Locked palette token + existing trust-surface tests untouched.
@oxoxDev oxoxDev requested a review from a team May 26, 2026 05:04
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

📝 Walkthrough

Walkthrough

The PR implements a security-focused one-way reveal gate for the recovery phrase: the mnemonic is hidden behind a blur overlay and marked aria-hidden until the user explicitly clicks to reveal it. Copy-to-clipboard is disabled and visually grayed out until revealed, and the reveal state resets when the user switches between generate and import modes.

Changes

Recovery Phrase Reveal Gate

Layer / File(s) Summary
Reveal state and copy handler logic
app/src/components/settings/panels/RecoveryPhrasePanel.tsx
Introduces revealed state to control phrase visibility, resets reveal state when switching modes, guards the copy handler with an early return unless revealed, and updates hook dependencies to track reveal state.
UI display: grid blur and reveal overlay
app/src/components/settings/panels/RecoveryPhrasePanel.tsx
Updates mnemonic grid to conditionally blur and block pointer events based on reveal state, adds a clickable overlay reveal button shown only when not revealed, and disables the copy button with aria-disabled, conditional title, and opacity styling.
Test suite for reveal gate behavior
app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx
Adds tests verifying default hidden state (blurred grid, overlay button present, aria-hidden), copy button disabled until reveal, state transitions after clicking reveal (overlay removed, grid unblurred, copy enabled), clipboard write only after reveal with proper mnemonic format, and state reset on mode changes.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • tinyhumansai/openhuman#2658: Implements the same one-way reveal gate state in RecoveryPhrasePanel with blur/aria-hidden masking and copy-to-clipboard gating until explicit reveal.

Suggested labels

working

Poem

🐰 A phrase once bare, now hides from prying sight,
Behind a veil, awaiting finger's slight,
Click to reveal what once was carelessly laid—
A guardian's gesture, a privacy parade! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly matches the main changeset: requiring manual reveal before showing the seed phrase, with specific reference to issue #2657.
Linked Issues check ✅ Passed All coding requirements from issue #2657 are met: default blur state, pointer-events blocking, one-way reveal, disabled copy until revealed, and mode-switch re-arming of the gate.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the reveal gate for the recovery phrase; no unrelated modifications or feature creep detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/2657-recovery-phrase-blur

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the working A PR that is being worked on by the team. label May 26, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx`:
- Around line 145-150: The test "writes the mnemonic to clipboard only after
reveal" mutates navigator.clipboard and doesn't restore it; save the original
clipboard (e.g., const originalClipboard = navigator.clipboard) before calling
Object.defineProperty, use your mocked writeText vi.fn for the test, and restore
navigator.clipboard to originalClipboard in a finally block or an afterEach
cleanup so the global is returned to its prior state (reference the test name
and the mocked writeText/navigator.clipboard setup to locate where to add the
save/restore).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a843d4d9-8e50-4a22-a8d0-571bf965f9ec

📥 Commits

Reviewing files that changed from the base of the PR and between b6d3158 and 6578565.

📒 Files selected for processing (2)
  • app/src/components/settings/panels/RecoveryPhrasePanel.tsx
  • app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx

Comment on lines +145 to +150
it('writes the mnemonic to clipboard only after reveal', async () => {
const writeText = vi.fn<(text: string) => Promise<void>>(async () => {
return;
});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
renderWithProviders(<RecoveryPhrasePanel />);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore navigator.clipboard after this test to avoid global leakage.

This test mutates a global and never restores it, which can make later tests order-dependent.

Suggested fix
   it('writes the mnemonic to clipboard only after reveal', async () => {
+    const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
     const writeText = vi.fn<(text: string) => Promise<void>>(async () => {
       return;
     });
     Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
-    renderWithProviders(<RecoveryPhrasePanel />);
-
-    fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
-    const copyBtn = screen
-      .getAllByRole('button')
-      .find(b => /copy to clipboard/i.test(b.textContent || ''))!;
-    fireEvent.click(copyBtn);
-
-    expect(writeText).toHaveBeenCalledTimes(1);
-    const arg = writeText.mock.calls[0]?.[0] ?? '';
-    // BIP-39 mnemonics are space-separated words.
-    expect(arg.split(' ').length).toBeGreaterThanOrEqual(12);
+    try {
+      renderWithProviders(<RecoveryPhrasePanel />);
+      fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
+      const copyBtn = screen
+        .getAllByRole('button')
+        .find(b => /copy to clipboard/i.test(b.textContent || ''))!;
+      fireEvent.click(copyBtn);
+
+      expect(writeText).toHaveBeenCalledTimes(1);
+      const arg = writeText.mock.calls[0]?.[0] ?? '';
+      expect(arg.split(' ').length).toBeGreaterThanOrEqual(12);
+    } finally {
+      if (originalClipboard) {
+        Object.defineProperty(navigator, 'clipboard', originalClipboard);
+      }
+    }
   });
As per coding guidelines: “Keep tests deterministic: avoid real network calls, time-sensitive flakes, or hidden global state.”
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('writes the mnemonic to clipboard only after reveal', async () => {
const writeText = vi.fn<(text: string) => Promise<void>>(async () => {
return;
});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
renderWithProviders(<RecoveryPhrasePanel />);
it('writes the mnemonic to clipboard only after reveal', async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
const writeText = vi.fn<(text: string) => Promise<void>>(async () => {
return;
});
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
try {
renderWithProviders(<RecoveryPhrasePanel />);
fireEvent.click(screen.getByRole('button', { name: /reveal phrase/i }));
const copyBtn = screen
.getAllByRole('button')
.find(b => /copy to clipboard/i.test(b.textContent || ''))!;
fireEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledTimes(1);
const arg = writeText.mock.calls[0]?.[0] ?? '';
expect(arg.split(' ').length).toBeGreaterThanOrEqual(12);
} finally {
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
}
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx`
around lines 145 - 150, The test "writes the mnemonic to clipboard only after
reveal" mutates navigator.clipboard and doesn't restore it; save the original
clipboard (e.g., const originalClipboard = navigator.clipboard) before calling
Object.defineProperty, use your mocked writeText vi.fn for the test, and restore
navigator.clipboard to originalClipboard in a finally block or an afterEach
cleanup so the global is returned to its prior state (reference the test name
and the mocked writeText/navigator.clipboard setup to locate where to add the
save/restore).

@oxoxDev
Copy link
Copy Markdown
Contributor Author

oxoxDev commented May 26, 2026

Closing as duplicate — PR #2658 by @iamrhn merged 2026-05-26T05:02Z (90 minutes before this PR opened) addresses the same #2657 with the same blur-by-default + manual-reveal pattern.

Apologies for the noise — I didn't catch the in-flight PR in my dedup pass.

@oxoxDev oxoxDev closed this May 26, 2026
@oxoxDev oxoxDev deleted the fix/2657-recovery-phrase-blur branch May 26, 2026 06:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recovery phrase should require manual reveal

1 participant