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 { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { copyToClipboard } from '../utils/dom';
|
import { useSensitiveCopy } from '../hooks/useSensitiveCopy';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
||||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||||
@@ -232,9 +232,8 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const saveFile = useSaveFile();
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
// [Gitea #156] The key leaves the clipboard again after 60 s, visibly.
|
||||||
copyToClipboard(recoveryKey);
|
const { copy: handleCopy, secondsLeft } = useSensitiveCopy(recoveryKey);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
@@ -272,8 +271,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button onClick={handleCopy}>
|
<Button onClick={handleCopy} aria-live="polite">
|
||||||
<Text size="B400">Copy</Text>
|
<Text size="B400">
|
||||||
|
{secondsLeft !== null ? `Copied · clears in ${secondsLeft} s` : 'Copy'}
|
||||||
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDownload} fill="Soft">
|
<Button onClick={handleDownload} fill="Soft">
|
||||||
<Text size="B400">Download</Text>
|
<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