Session verification by emojis (#513)
* Add option to verify with security key/phrase * Manually merge #311 by @ginnyTheCat
This commit is contained in:
parent
416fd02069
commit
2867bb3bc3
9 changed files with 270 additions and 12 deletions
153
src/app/organisms/emoji-verification/EmojiVerification.jsx
Normal file
153
src/app/organisms/emoji-verification/EmojiVerification.jsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EmojiVerification.scss';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
function EmojiVerificationContent({ request, requestClose }) {
|
||||
const [sas, setSas] = useState(null);
|
||||
const [process, setProcess] = useState(false);
|
||||
const mountStore = useStore();
|
||||
mountStore.setItem(true);
|
||||
|
||||
const handleChange = () => {
|
||||
if (request.done || request.cancelled) requestClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
if (request === null) return null;
|
||||
const req = request;
|
||||
req.on('change', handleChange);
|
||||
return () => req.off('change', handleChange);
|
||||
}, [request]);
|
||||
|
||||
const acceptRequest = async () => {
|
||||
setProcess(true);
|
||||
await request.accept();
|
||||
|
||||
const verifier = request.beginKeyVerification('m.sas.v1');
|
||||
verifier.on('show_sas', (data) => {
|
||||
if (!mountStore.getItem()) return;
|
||||
setSas(data);
|
||||
setProcess(false);
|
||||
});
|
||||
await verifier.verify();
|
||||
};
|
||||
|
||||
const sasMismatch = () => {
|
||||
sas.mismatch();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
const sasConfirm = () => {
|
||||
sas.confirm();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
const renderWait = () => (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>Waiting for response from other device...</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
if (sas !== null) {
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
||||
<div className="emoji-verification__emojis">
|
||||
{sas.sas.emoji.map((emoji) => (
|
||||
<div className="emoji-verification__emoji-block" key={emoji[1]}>
|
||||
<Text variant="h1">{twemojify(emoji[0])}</Text>
|
||||
<Text>{emoji[1]}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="emoji-verification__buttons">
|
||||
{process ? renderWait() : (
|
||||
<>
|
||||
<Button variant="primary" onClick={sasConfirm}>They match</Button>
|
||||
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Click accept to start the verification process</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{
|
||||
process
|
||||
? renderWait()
|
||||
: <Button variant="primary" onClick={acceptRequest}>Accept</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
EmojiVerificationContent.propTypes = {
|
||||
request: PropTypes.shape({}).isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [request, setRequest] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (req) => setRequest(req);
|
||||
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.on('crypto.verification.request', handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.removeListener('crypto.verification.request', handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setRequest(null);
|
||||
|
||||
return [request, requestClose];
|
||||
}
|
||||
|
||||
function EmojiVerification() {
|
||||
const [request, requestClose] = useVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={request !== null}
|
||||
className="emoji-verification"
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Emoji verification
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
request !== null
|
||||
? <EmojiVerificationContent request={request} requestClose={requestClose} />
|
||||
: <div />
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiVerification;
|
35
src/app/organisms/emoji-verification/EmojiVerification.scss
Normal file
35
src/app/organisms/emoji-verification/EmojiVerification.scss
Normal file
|
@ -0,0 +1,35 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.emoji-verification {
|
||||
&__content {
|
||||
padding: var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__emojis {
|
||||
margin: var(--sp-loose) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--sp-extra-tight);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__emoji-block {
|
||||
@extend .cp-fx__column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--sp-extra-tight);
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExistin
|
|||
import Search from '../search/Search';
|
||||
import ViewSource from '../view-source/ViewSource';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
||||
|
||||
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||
|
||||
|
@ -20,6 +21,7 @@ function Dialogs() {
|
|||
<CreateRoom />
|
||||
<SpaceAddExisting />
|
||||
<Search />
|
||||
<EmojiVerification />
|
||||
|
||||
<ReusableDialog />
|
||||
</>
|
||||
|
|
|
@ -4,7 +4,7 @@ import dateFormat from 'dateformat';
|
|||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { openReusableDialog, openEmojiVerification } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
@ -25,6 +25,7 @@ import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
|||
import { useStore } from '../../hooks/useStore';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
import { accessSecretStorage } from './SecretStorageAccess';
|
||||
|
||||
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
|
@ -69,6 +70,7 @@ function DeviceManage() {
|
|||
const [truncated, setTruncated] = useState(true);
|
||||
const mountStore = useStore();
|
||||
mountStore.setItem(true);
|
||||
const isMeVerified = isCrossVerified(mx.deviceId);
|
||||
|
||||
useEffect(() => {
|
||||
setProcessing([]);
|
||||
|
@ -127,18 +129,41 @@ function DeviceManage() {
|
|||
removeFromProcessing(device);
|
||||
};
|
||||
|
||||
const verifyWithKey = async (device) => {
|
||||
const keyData = await accessSecretStorage('Session verification');
|
||||
if (!keyData) return;
|
||||
addToProcessing(device);
|
||||
await mx.checkOwnCrossSigningTrust();
|
||||
};
|
||||
|
||||
const verifyWithEmojis = async (deviceId) => {
|
||||
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
|
||||
openEmojiVerification(req);
|
||||
};
|
||||
|
||||
const verify = (deviceId, isCurrentDevice) => {
|
||||
if (isCurrentDevice) {
|
||||
verifyWithKey(deviceId);
|
||||
return;
|
||||
}
|
||||
verifyWithEmojis(deviceId);
|
||||
};
|
||||
|
||||
const renderDevice = (device, isVerified) => {
|
||||
const deviceId = device.device_id;
|
||||
const displayName = device.display_name;
|
||||
const lastIP = device.last_seen_ip;
|
||||
const lastTS = device.last_seen_ts;
|
||||
const isCurrentDevice = mx.deviceId === deviceId;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
key={deviceId}
|
||||
title={(
|
||||
<Text style={{ color: isVerified ? '' : 'var(--tc-danger-high)' }}>
|
||||
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
|
||||
{displayName}
|
||||
<Text variant="b3" span>{` — ${deviceId}${mx.deviceId === deviceId ? ' (current)' : ''}`}</Text>
|
||||
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
|
||||
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
|
||||
</Text>
|
||||
)}
|
||||
options={
|
||||
|
@ -146,12 +171,14 @@ function DeviceManage() {
|
|||
? <Spinner size="small" />
|
||||
: (
|
||||
<>
|
||||
{((isMeVerified && isVerified === false) || (isCurrentDevice && isVerified === false)) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
|
||||
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
|
||||
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
content={(
|
||||
<>
|
||||
<Text variant="b3">
|
||||
Last activity
|
||||
<span style={{ color: 'var(--tc-surface-normal)' }}>
|
||||
|
@ -159,6 +186,12 @@ function DeviceManage() {
|
|||
</span>
|
||||
{lastIP ? ` at ${lastIP}` : ''}
|
||||
</Text>
|
||||
{isCurrentDevice && (
|
||||
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
|
||||
{`Session Key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
@ -200,7 +233,7 @@ function DeviceManage() {
|
|||
{noEncryption.length > 0 && (
|
||||
<div>
|
||||
<MenuHeader>Sessions without encryption support</MenuHeader>
|
||||
{noEncryption.map((device) => renderDevice(device, true))}
|
||||
{noEncryption.map((device) => renderDevice(device, null))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
|
@ -211,7 +244,7 @@ function DeviceManage() {
|
|||
if (truncated && index >= TRUNCATED_COUNT) return null;
|
||||
return renderDevice(device, true);
|
||||
})
|
||||
: <Text className="device-manage__info">No verified session</Text>
|
||||
: <Text className="device-manage__info">No verified sessions</Text>
|
||||
}
|
||||
{ verified.length > TRUNCATED_COUNT && (
|
||||
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
|
||||
|
|
|
@ -15,6 +15,23 @@
|
|||
& .setting-tile:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
& .setting-tile__options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-ultra-tight);
|
||||
& .btn-positive {
|
||||
padding: 6px var(--sp-tight);
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__current-label {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: 2px var(--sp-ultra-tight);
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--tc-surface-low);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__rename {
|
||||
padding: var(--sp-normal);
|
||||
|
|
|
@ -166,3 +166,10 @@ export function openReusableDialog(title, render, afterClose) {
|
|||
afterClose,
|
||||
});
|
||||
}
|
||||
|
||||
export function openEmojiVerification(request) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_EMOJI_VERIFICATION,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -38,6 +38,9 @@ class InitMatrix extends EventEmitter {
|
|||
deviceId: secret.deviceId,
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks,
|
||||
verificationMethods: [
|
||||
'm.sas.v1',
|
||||
],
|
||||
});
|
||||
|
||||
await this.matrixClient.initCrypto();
|
||||
|
|
|
@ -49,6 +49,7 @@ const cons = {
|
|||
OPEN_REUSABLE_CONTEXT_MENU: 'OPEN_REUSABLE_CONTEXT_MENU',
|
||||
OPEN_NAVIGATION: 'OPEN_NAVIGATION',
|
||||
OPEN_REUSABLE_DIALOG: 'OPEN_REUSABLE_DIALOG',
|
||||
OPEN_EMOJI_VERIFICATION: 'OPEN_EMOJI_VERIFICATION',
|
||||
},
|
||||
room: {
|
||||
JOIN: 'JOIN',
|
||||
|
@ -96,6 +97,7 @@ const cons = {
|
|||
REUSABLE_CONTEXT_MENU_OPENED: 'REUSABLE_CONTEXT_MENU_OPENED',
|
||||
NAVIGATION_OPENED: 'NAVIGATION_OPENED',
|
||||
REUSABLE_DIALOG_OPENED: 'REUSABLE_DIALOG_OPENED',
|
||||
EMOJI_VERIFICATION_OPENED: 'EMOJI_VERIFICATION_OPENED',
|
||||
},
|
||||
roomList: {
|
||||
ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
|
||||
|
|
|
@ -185,6 +185,12 @@ class Navigation extends EventEmitter {
|
|||
action.afterClose,
|
||||
);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_EMOJI_VERIFICATION]: () => {
|
||||
this.emit(
|
||||
cons.events.navigation.EMOJI_VERIFICATION_OPENED,
|
||||
action.request,
|
||||
);
|
||||
},
|
||||
};
|
||||
actions[action.type]?.();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue