Files
cinny/src/app/components/DeviceVerification.tsx
T
jared 17bd50cc4e
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 7s
feat(crypto): QR-code device verification (alongside emoji SAS)
B2 of the Matrix protocol-gaps roadmap, gate-green (688 tests):
- Enable QR verification methods (show/scan/reciprocate) in initMatrix.
- Extend DeviceVerification: the Ready step offers your own QR (byte-mode encode
  via qrcode), a camera 'Scan their QR code' flow, and an emoji fallback; the
  Started step routes reciprocate → a confirm step (useVerifierShowReciprocateQr)
  or SAS as before.
- New QrScanner component: getUserMedia + jsQR, handing the raw binaryData bytes
  to request.scanQRCode (BarcodeDetector is string-only, so can't be used).
- Adds qrcode + jsqr (small, pure-JS, client-only); build-verified under rolldown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:30:23 -04:00

441 lines
13 KiB
TypeScript

import {
ShowQrCodeCallbacks,
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import QRCode from 'qrcode';
import {
Box,
Button,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowReciprocateQr,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { useModalStyle } from '../hooks/useModalStyle';
import { QrScanner } from './QrScanner';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
};
type WaitingMessageProps = {
message: string;
};
function WaitingMessage({ message }: WaitingMessageProps) {
return (
<Box alignItems="Center" gap="200">
<Spinner variant="Secondary" size="200" />
<Text size="T300">{message}</Text>
</Box>
);
}
type VerificationUnexpectedProps = { message: string; onClose: () => void };
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button>
</Box>
);
}
function VerificationWaitAccept() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
</Box>
);
}
type VerificationAcceptProps = {
onAccept: () => Promise<void>;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const { t } = useTranslation();
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
<Button
variant="Primary"
fill="Solid"
onClick={accept}
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting}
>
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
</Button>
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
borderRadius: config.radii.R400,
padding: config.space.S500,
}}
gap="700"
wrap="Wrap"
justifyContent="Center"
>
{sasData.sas.emoji?.map(([emoji, name], index) => (
<Box
key={`${emoji}${name}${index}`}
direction="Column"
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Text size="H1">{emoji}</Text>
<Text size="T200">{name}</Text>
</Box>
))}
</Box>
<Box direction="Column" gap="200">
<Button
variant="Primary"
fill="Soft"
onClick={confirm}
disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />}
>
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
</Button>
</Box>
</Box>
);
}
type SasVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const { t } = useTranslation();
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
useEffect(() => {
verifier.verify();
}, [verifier]);
if (sasData) {
return <CompareEmoji sasData={sasData} />;
}
return (
<Box direction="Column" gap="400">
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
type VerificationDoneProps = {
onExit: () => void;
};
function VerificationDone({ onExit }: VerificationDoneProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<div>
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
</div>
<Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
</Button>
</Box>
);
}
type VerificationCanceledProps = {
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button>
</Box>
);
}
function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Byte-mode so the raw verification bytes round-trip (a string value would
// mangle high bytes via UTF-8).
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
width: 220,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
}).catch(() => undefined);
}, [data]);
return (
<Box justifyContent="Center">
<canvas ref={canvasRef} style={{ borderRadius: config.radii.R300 }} />
</Box>
);
}
type VerificationReadyProps = {
request: VerificationRequest;
onStartSas: () => void;
onScanned: (bytes: Uint8ClampedArray) => void;
};
function VerificationReady({ request, onStartSas, onScanned }: VerificationReadyProps) {
const [myQr, setMyQr] = useState<Uint8ClampedArray>();
const [scanning, setScanning] = useState(false);
const canShowMine = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
const canScanTheirs = request.otherPartySupportsMethod(VerificationMethod.ShowQrCode);
useEffect(() => {
if (!canShowMine) return;
request
.generateQRCode()
.then((bytes) => {
if (bytes) setMyQr(bytes);
})
.catch(() => undefined);
}, [request, canShowMine]);
if (scanning) {
return <QrScanner onScan={onScanned} onCancel={() => setScanning(false)} />;
}
return (
<Box direction="Column" gap="400">
{myQr && (
<Box direction="Column" gap="200">
<Text size="T300">Scan this code with your other device to verify.</Text>
<QrCodeImage data={myQr} />
</Box>
)}
<Box direction="Column" gap="200">
{canScanTheirs && (
<Button variant="Primary" fill="Solid" onClick={() => setScanning(true)}>
<Text size="B400">Scan their QR code</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={onStartSas}>
<Text size="B400">Verify with emoji instead</Text>
</Button>
</Box>
</Box>
);
}
type ReciprocateVerificationProps = {
verifier: Verifier;
onCancel: () => void;
};
function ReciprocateVerification({ verifier, onCancel }: ReciprocateVerificationProps) {
const [qrCallbacks, setQrCallbacks] = useState<ShowQrCodeCallbacks>();
const [confirmState, confirm] = useAsyncCallback(
useCallback(async () => qrCallbacks?.confirm(), [qrCallbacks]),
);
useVerifierShowReciprocateQr(verifier, setQrCallbacks);
useVerifierCancel(verifier, onCancel);
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
// The showing side gets ShowReciprocateQr callbacks after the other device
// scans; the scanning side never does (it already called verify()) and just
// waits for completion.
if (!qrCallbacks) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Verifying…" />
</Box>
);
}
return (
<Box direction="Column" gap="400">
<Text>The other device scanned this code. Confirm it now shows as verified.</Text>
<Box direction="Column" gap="200">
<Button variant="Primary" fill="Soft" onClick={confirm} disabled={confirming}>
<Text size="B400">Confirm</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={() => qrCallbacks.cancel()}
disabled={confirming}
>
<Text size="B400">Cancel</Text>
</Button>
</Box>
</Box>
);
}
type DeviceVerificationProps = {
request: VerificationRequest;
onExit: () => void;
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const phase = useVerificationRequestPhase(request);
const modalStyle = useModalStyle(480);
const handleCancel = useCallback(() => {
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
request.cancel();
}
onExit();
}, [request, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
const handleScanned = useCallback(
async (bytes: Uint8ClampedArray) => {
try {
const verifier = await request.scanQRCode(bytes);
await verifier.verify();
} catch {
// A bad/mismatched scan cancels the request; the Cancelled phase renders.
}
},
[request],
);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: false,
escapeDeactivates: false,
}}
>
<Dialog variant="Surface" style={modalStyle}>
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text as="h2" size="H4">
Device Verification
</Text>
</Box>
<IconButton
size="300"
radii="300"
onClick={handleCancel}
aria-label="Cancel verification"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready && (
<VerificationReady
request={request}
onStartSas={handleStart}
onScanned={handleScanned}
/>
)}
{phase === VerificationPhase.Started &&
(request.verifier ? (
request.chosenMethod === VerificationMethod.Reciprocate ? (
<ReciprocateVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
)
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
)}
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<VerificationRequest>();
useVerificationRequestReceived(setRequest);
const handleExit = useCallback(() => {
setRequest(undefined);
}, []);
if (!request) return null;
return <DeviceVerification request={request} onExit={handleExit} />;
}