feat(security): recovery key leaves the clipboard after 60 s, visibly (#156)

useSensitiveCopy: the recovery key's Copy button becomes 'Copied · clears in
60 s' and counts down; at zero the clipboard is cleared only if it still holds
the key (readText() where permitted — if the browser refuses to read, nothing
is wiped rather than risk eating something else). Any other copy made in the
app cancels the timer. No setting. Verified headless with a fake clock:
countdown ticks, clipboard emptied at 0; copying something else mid-countdown
cancels and leaves that content untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 23:06:02 -04:00
co-authored by Claude Opus 5
parent 74d8e3119b
commit 9363629ea2
2 changed files with 77 additions and 6 deletions
@@ -19,7 +19,7 @@ import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { useSensitiveCopy } from '../hooks/useSensitiveCopy';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
@@ -232,9 +232,8 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => {
copyToClipboard(recoveryKey);
};
// [Gitea #156] The key leaves the clipboard again after 60 s, visibly.
const { copy: handleCopy, secondsLeft } = useSensitiveCopy(recoveryKey);
const handleDownload = () => {
const blob = new Blob([recoveryKey], {
@@ -272,8 +271,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
<Button onClick={handleCopy} aria-live="polite">
<Text size="B400">
{secondsLeft !== null ? `Copied · clears in ${secondsLeft} s` : 'Copy'}
</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>