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:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user