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>
+70
View File
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { copyToClipboard } from '../utils/dom';
export const SENSITIVE_COPY_TTL_S = 60;
/**
* [Gitea #156] Copy a secret (recovery key) and clear it from the clipboard
* 60 s later — visibly: `secondsLeft` counts down for the button label. At
* zero the clipboard is cleared ONLY if it still holds our value
* (`readText()` where the browser allows it; when it doesn't, nothing is
* wiped — better to leave a key than to eat something else the user copied).
* Any other copy made in the app cancels the timer.
*/
export function useSensitiveCopy(value: string): {
copy: () => void;
secondsLeft: number | null;
} {
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const timer = useRef<number | undefined>(undefined);
const armed = useRef(false);
const cancel = useCallback(() => {
if (timer.current !== undefined) window.clearInterval(timer.current);
timer.current = undefined;
armed.current = false;
setSecondsLeft(null);
}, []);
useEffect(() => {
// Our own copy fires a `copy` event too; `armed` is set right after it.
const onCopy = () => {
if (armed.current) cancel();
};
document.addEventListener('copy', onCopy);
return () => {
document.removeEventListener('copy', onCopy);
cancel();
};
}, [cancel]);
const copy = useCallback(() => {
cancel();
copyToClipboard(value);
// Arm after this tick so the copy event of THIS copy doesn't cancel it.
window.setTimeout(() => {
armed.current = true;
}, 0);
let left = SENSITIVE_COPY_TTL_S;
setSecondsLeft(left);
timer.current = window.setInterval(() => {
left -= 1;
if (left > 0) {
setSecondsLeft(left);
return;
}
const clear = async () => {
try {
const current = await navigator.clipboard.readText();
if (current === value) await navigator.clipboard.writeText('');
} catch {
// read denied or unavailable — leave the clipboard alone
}
};
clear().catch(() => undefined);
cancel();
}, 1000);
}, [value, cancel]);
return { copy, secondsLeft };
}