Session verification by emojis (#513)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 1 May 2022 07:52:55 +0000 (13:22 +0530)
committerGitHub <noreply@github.com>
Sun, 1 May 2022 07:52:55 +0000 (13:22 +0530)
* Add option to verify with security key/phrase

* Manually merge #311 by @ginnyTheCat

src/app/organisms/emoji-verification/EmojiVerification.jsx [new file with mode: 0644]
src/app/organisms/emoji-verification/EmojiVerification.scss [new file with mode: 0644]
src/app/organisms/pw/Dialogs.jsx
src/app/organisms/settings/DeviceManage.jsx
src/app/organisms/settings/DeviceManage.scss
src/client/action/navigation.js
src/client/initMatrix.js
src/client/state/cons.js
src/client/state/navigation.js

diff --git a/src/app/organisms/emoji-verification/EmojiVerification.jsx b/src/app/organisms/emoji-verification/EmojiVerification.jsx
new file mode 100644 (file)
index 0000000..f56a467
--- /dev/null
@@ -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;
diff --git a/src/app/organisms/emoji-verification/EmojiVerification.scss b/src/app/organisms/emoji-verification/EmojiVerification.scss
new file mode 100644 (file)
index 0000000..4e6dc11
--- /dev/null
@@ -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);
+  }
+}
index f29a819215c77b7ca0c70ce9d43a8f9cddf7dfab..28cb47ad26262fa03cb7242bcd24e475d2242b7e 100644 (file)
@@ -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 />
     </>
index b6ce307b151f1437355589e7cf0f30bc52968c78..a7409aa2f31a4b25c40d452fdb969f3599054406 100644 (file)
@@ -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,19 +171,27 @@ 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)' }}>
-              {dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
-            </span>
-            {lastIP ? ` at ${lastIP}` : ''}
-          </Text>
+          <>
+            <Text variant="b3">
+              Last activity
+              <span style={{ color: 'var(--tc-surface-normal)' }}>
+                {dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
+              </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)}>
index 0a8fc4a986196316ac67882698c84dc36e7cc030..5116cda8815e2fecf873d3b664cd847604884a81 100644 (file)
   & .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);
index e05cf5111f745c14950a26e89df121211069355d..9e8594944408009a90ae98bd93e4a8bb264ef478 100644 (file)
@@ -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,
+  });
+}
index 0e5cd0d05a70b3761fed60af2f0df44d89799e03..aec2f3da3d11ef3fab3cfbd18ad2056d1beda5d6 100644 (file)
@@ -38,6 +38,9 @@ class InitMatrix extends EventEmitter {
       deviceId: secret.deviceId,
       timelineSupport: true,
       cryptoCallbacks,
+      verificationMethods: [
+        'm.sas.v1',
+      ],
     });
 
     await this.matrixClient.initCrypto();
index 34a3a928bb04ce0da87a91756a5ea8f475075d97..789ed587769dd0add0ad9ce1ff3fbcf7c4f9333e 100644 (file)
@@ -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',
index bbecb2e13cc92174d31cce4d7ceeaaa7f6b3ed1c..42ab01995c3c70691e67e19d957ab4dccc9be7b7 100644 (file)
@@ -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]?.();
   }