From 672567dfcf58294d615d0ff3c634b7c585b8733e Mon Sep 17 00:00:00 2001 From: waterWang Date: Mon, 3 Aug 2026 06:12:50 +0800 Subject: [PATCH] feat: replace clipboard copy with QR code for secret key backup (SEC-25) - Add QR code display as the primary secret key backup method - Keep clipboard copy as secondary option with security warning - Add session-level rate limiting (max 1 export per 5 minutes) - Auto-clear clipboard after 30 seconds - Add clipboard risk warning banner after copy - Add comprehensive tests for all new behaviors - Update en.json and es.json translations with new keys - Add fadeIn animation utility class Closes #348 --- micopay/frontend/package-lock.json | 2 +- micopay/frontend/src/__tests__/setup.ts | 14 +- .../components/ExportSecretKeyModal.test.tsx | 180 ++++++++++++++++++ .../src/components/ExportSecretKeyModal.tsx | 130 ++++++++++++- micopay/frontend/src/i18n/en.json | 5 + micopay/frontend/src/i18n/es.json | 5 + micopay/frontend/src/index.css | 7 + 7 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 micopay/frontend/src/components/ExportSecretKeyModal.test.tsx diff --git a/micopay/frontend/package-lock.json b/micopay/frontend/package-lock.json index 75e44f9..072b259 100644 --- a/micopay/frontend/package-lock.json +++ b/micopay/frontend/package-lock.json @@ -10503,7 +10503,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/micopay/frontend/src/__tests__/setup.ts b/micopay/frontend/src/__tests__/setup.ts index a1ab34b..3b57e4f 100644 --- a/micopay/frontend/src/__tests__/setup.ts +++ b/micopay/frontend/src/__tests__/setup.ts @@ -1,10 +1,18 @@ import '@testing-library/jest-dom'; -import { webcrypto } from 'crypto'; +// Polyfill crypto.getRandomValues for jsdom environment if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.getRandomValues) { Object.defineProperty(globalThis, 'crypto', { - value: webcrypto, + value: { + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + return arr; + }, + subtle: {} as SubtleCrypto, + } as Crypto, writable: true, configurable: true, }); -} +} \ No newline at end of file diff --git a/micopay/frontend/src/components/ExportSecretKeyModal.test.tsx b/micopay/frontend/src/components/ExportSecretKeyModal.test.tsx new file mode 100644 index 0000000..9c4da81 --- /dev/null +++ b/micopay/frontend/src/components/ExportSecretKeyModal.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import ExportSecretKeyModal from './ExportSecretKeyModal'; +import { exportSecretKey } from '../lib/keystore'; + +// Mock the keystore module +vi.mock('../lib/keystore', () => ({ + exportSecretKey: vi.fn(), +})); + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'profile.exportKeyClose': 'Close', + 'profile.exportKeyTitle': 'Backup your key', + 'profile.exportKeyHeading': 'Your Secret Key', + 'profile.exportKeyWarning': 'MicoPay never stores your secret key on our servers. Anyone with this key has full control of your funds.', + 'profile.exportKeyLabel': 'Secret Key', + 'profile.exportKeyShow': 'Reveal', + 'profile.exportKeyHide': 'Hide', + 'profile.exportKeyCopy': 'Copy to Clipboard', + 'profile.exportKeyCopied': 'Copied!', + 'profile.exportKeyQR': 'Scan to backup', + 'profile.exportKeyQRHint': 'Screenshot or print this QR code.', + 'profile.exportClipboardWarning': 'Clipboard warning: Android clipboard is readable by other apps.', + 'profile.exportRateLimited': 'Export rate-limited. Try again in', + 'profile.exportKeyError': 'Failed to load secret key.', + }; + return translations[key] ?? key; + }, + }), +})); + +// Mock clipboard API +const mockWriteText = vi.fn(); +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}); + +const MOCK_SECRET_KEY = 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +describe('ExportSecretKeyModal — SEC-25 QR + Clipboard security', () => { + const onClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + (exportSecretKey as ReturnType).mockResolvedValue(MOCK_SECRET_KEY); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders QR code after loading the secret key', async () => { + render(); + + // Should show loading state initially + expect(screen.getByText('Your Secret Key')).toBeInTheDocument(); + + // Wait for the secret key to load + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + // QR code should be rendered — the SVG element is from qrcode.react + await waitFor(() => { + const svg = document.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + }); + + it('renders clipboard warning after copying to clipboard', async () => { + mockWriteText.mockResolvedValue(undefined); + render(); + + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + // Click copy button + const copyBtn = screen.getByText('Copy to Clipboard'); + fireEvent.click(copyBtn); + + // Should show clipboard warning + await waitFor(() => { + expect(screen.getByText(/Clipboard warning/)).toBeInTheDocument(); + }); + + // Should have written to clipboard + expect(mockWriteText).toHaveBeenCalledWith(MOCK_SECRET_KEY); + }); + + it('shows rate-limit indicator after copying', async () => { + mockWriteText.mockResolvedValue(undefined); + render(); + + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + // Click copy button + const copyBtn = screen.getByText('Copy to Clipboard'); + fireEvent.click(copyBtn); + + // Should show rate-limited message + await waitFor(() => { + expect(screen.getByText(/rate-limited/)).toBeInTheDocument(); + }); + + // Copy button should be disabled now + expect(copyBtn.closest('button')).toBeDisabled(); + }); + + it('auto-clears clipboard after 30 seconds', async () => { + mockWriteText.mockResolvedValue(undefined); + render(); + + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + // Click copy button + const copyBtn = screen.getByText('Copy to Clipboard'); + fireEvent.click(copyBtn); + + // Advance time by 30 seconds + act(() => { + vi.advanceTimersByTime(30000); + }); + + // Clipboard should have been cleared (written with empty string) + expect(mockWriteText).toHaveBeenCalledWith(''); + }); + + it('shows masked secret key by default and reveals on toggle', async () => { + render(); + + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + // Should show masked dots + expect(screen.getByText('•'.repeat(56))).toBeInTheDocument(); + + // Click reveal + const revealBtn = screen.getByText('Reveal'); + fireEvent.click(revealBtn); + + // Should show the actual key + expect(screen.getByText(MOCK_SECRET_KEY)).toBeInTheDocument(); + expect(screen.getByText('Hide')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', async () => { + render(); + + await waitFor(() => { + expect(exportSecretKey).toHaveBeenCalledTimes(1); + }); + + const closeBtn = screen.getByText('Close'); + fireEvent.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows error state when loading fails', async () => { + (exportSecretKey as ReturnType).mockRejectedValue(new Error('load failed')); + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to load secret key.')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/micopay/frontend/src/components/ExportSecretKeyModal.tsx b/micopay/frontend/src/components/ExportSecretKeyModal.tsx index 4572e65..b5cc3b2 100644 --- a/micopay/frontend/src/components/ExportSecretKeyModal.tsx +++ b/micopay/frontend/src/components/ExportSecretKeyModal.tsx @@ -1,18 +1,30 @@ - -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { QRCodeSVG } from "qrcode.react"; import { exportSecretKey } from "../lib/keystore"; interface ExportSecretKeyModalProps { onClose: () => void; } +/** + * Session-level rate-limit: max 1 export attempt per 5 minutes. + * Resets on page reload (intentional — no persistent storage of the timer). + */ +const RATE_LIMIT_MS = 5 * 60 * 1000; // 5 minutes +let lastExportTime = 0; + const ExportSecretKeyModal = ({ onClose }: ExportSecretKeyModalProps) => { const { t } = useTranslation(); const [secretKey, setSecretKey] = useState(null); const [showKey, setShowKey] = useState(false); const [copied, setCopied] = useState(false); const [loading, setLoading] = useState(false); + const [clipboardWarning, setClipboardWarning] = useState(false); + const [rateLimited, setRateLimited] = useState(false); + const [rateLimitRemaining, setRateLimitRemaining] = useState(0); + const clipboardTimerRef = useRef | null>(null); + const rateLimitTimerRef = useRef | null>(null); const loadSecretKey = async () => { try { @@ -28,24 +40,75 @@ const ExportSecretKeyModal = ({ onClose }: ExportSecretKeyModalProps) => { useEffect(() => { loadSecretKey(); + return () => { + if (clipboardTimerRef.current) clearTimeout(clipboardTimerRef.current); + if (rateLimitTimerRef.current) clearTimeout(rateLimitTimerRef.current); + }; + }, []); + + /** Check if the user is rate-limited. */ + const checkRateLimit = useCallback((): boolean => { + const now = Date.now(); + const elapsed = now - lastExportTime; + if (elapsed < RATE_LIMIT_MS) { + const remaining = Math.ceil((RATE_LIMIT_MS - elapsed) / 1000); + setRateLimited(true); + setRateLimitRemaining(remaining); + // Countdown tick + if (rateLimitTimerRef.current) clearTimeout(rateLimitTimerRef.current); + rateLimitTimerRef.current = setTimeout(() => { + setRateLimited(false); + setRateLimitRemaining(0); + }, RATE_LIMIT_MS - elapsed); + return false; + } + return true; }, []); const handleCopy = async () => { if (!secretKey) return; + if (!checkRateLimit()) return; + try { await navigator.clipboard.writeText(secretKey); setCopied(true); + lastExportTime = Date.now(); + setClipboardWarning(true); + + // Show clipboard warning before auto-clearing + setClipboardWarning(true); + // Auto-clear clipboard after 30 seconds - setTimeout(() => { + if (clipboardTimerRef.current) clearTimeout(clipboardTimerRef.current); + clipboardTimerRef.current = setTimeout(() => { navigator.clipboard.writeText("").catch(() => {}); + setClipboardWarning(false); }, 30000); + // Reset copied state after 2 seconds setTimeout(() => setCopied(false), 2000); + + // Start rate-limit countdown + setRateLimited(true); + const remaining = RATE_LIMIT_MS / 1000; + setRateLimitRemaining(remaining); + if (rateLimitTimerRef.current) clearTimeout(rateLimitTimerRef.current); + rateLimitTimerRef.current = setTimeout(() => { + setRateLimited(false); + setRateLimitRemaining(0); + }, RATE_LIMIT_MS); } catch (err) { console.error("Failed to copy secret key:", err); } }; + /** Format remaining time as mm:ss */ + const formatRemaining = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; + }; + return (