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>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
VerificationPhase,
|
||||
VerificationRequest,
|
||||
Verifier,
|
||||
} from 'matrix-js-sdk/lib/crypto-api';
|
||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||
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,
|
||||
@@ -27,11 +29,13 @@ 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}`,
|
||||
@@ -97,32 +101,6 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function VerificationWaitStart() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type VerificationStartProps = {
|
||||
onStart: () => Promise<void>;
|
||||
};
|
||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
onStart();
|
||||
}, [onStart]);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||
const { t } = useTranslation();
|
||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||
@@ -237,6 +215,120 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -256,6 +348,17 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
||||
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 />}>
|
||||
@@ -290,15 +393,20 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
||||
) : (
|
||||
<VerificationAccept onAccept={handleAccept} />
|
||||
))}
|
||||
{phase === VerificationPhase.Ready &&
|
||||
(request.initiatedByMe ? (
|
||||
<AutoVerificationStart onStart={handleStart} />
|
||||
) : (
|
||||
<VerificationWaitStart />
|
||||
))}
|
||||
{phase === VerificationPhase.Ready && (
|
||||
<VerificationReady
|
||||
request={request}
|
||||
onStartSas={handleStart}
|
||||
onScanned={handleScanned}
|
||||
/>
|
||||
)}
|
||||
{phase === VerificationPhase.Started &&
|
||||
(request.verifier ? (
|
||||
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, color, config, Text } from 'folds';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
type QrScannerProps = {
|
||||
onScan: (bytes: Uint8ClampedArray) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
|
||||
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
|
||||
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
|
||||
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
const doneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let stream: MediaStream | undefined;
|
||||
let raf = 0;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const tick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = jsQR(image.data, image.width, image.height);
|
||||
if (result && result.binaryData.length > 0) {
|
||||
doneRef.current = true;
|
||||
onScan(new Uint8ClampedArray(result.binaryData));
|
||||
return;
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
});
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
} catch {
|
||||
setError(
|
||||
'Could not access the camera. Grant camera permission, or verify with emojis instead.',
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
doneRef.current = true;
|
||||
cancelAnimationFrame(raf);
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, [onScan]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{error}
|
||||
</Text>
|
||||
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
||||
<Text size="B400">Back</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="400" alignItems="Center">
|
||||
<Text size="T300" align="Center">
|
||||
Point your camera at the QR code shown on your other device.
|
||||
</Text>
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 280,
|
||||
borderRadius: config.radii.R400,
|
||||
background: '#000',
|
||||
}}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
<Button variant="Secondary" fill="Soft" onClick={onCancel}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
deviceId: session.deviceId,
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
verificationMethods: ['m.sas.v1'],
|
||||
// SAS (emoji) + QR-code verification (show/scan/reciprocate).
|
||||
verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'],
|
||||
tokenRefreshFunction: oidcRefresher
|
||||
? (refreshToken) => oidcRefresher.doRefreshAccessToken(refreshToken)
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user