Add support to manage cross-signing and key backup (#461)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 24 Apr 2022 10:12:24 +0000 (15:42 +0530)
committerGitHub <noreply@github.com>
Sun, 24 Apr 2022 10:12:24 +0000 (15:42 +0530)
* Add useDeviceList hook

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add isCrossVerified func to matrixUtil

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add className prop in sidebar avatar comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add unverified session indicator in sidebar

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add info card component

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add css variables

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add cross signin status hook

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add hasCrossSigninAccountData function

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add cross signin info card in device manage component

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add cross signing and key backup component

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Fix typo

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* WIP

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add cross singing dialogs

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add cross signing set/reset

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add SecretStorageAccess component

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* Add key backup

Signed-off-by: Ajay Bura <ajbura@gmail.com>
* WIP

* WIP

* WIP

* WIP

* Show progress when restoring key backup

* Add SSSS and key backup

26 files changed:
src/app/atoms/card/InfoCard.jsx [new file with mode: 0644]
src/app/atoms/card/InfoCard.scss [new file with mode: 0644]
src/app/hooks/useCrossSigningStatus.js [new file with mode: 0644]
src/app/hooks/useDeviceList.js [new file with mode: 0644]
src/app/molecules/dialog/Dialog.jsx
src/app/molecules/dialog/ReusableDialog.jsx
src/app/molecules/sidebar-avatar/SidebarAvatar.jsx
src/app/organisms/emoji-board/EmojiBoard.scss
src/app/organisms/navigation/SideBar.jsx
src/app/organisms/navigation/SideBar.scss
src/app/organisms/settings/AuthRequest.jsx [new file with mode: 0644]
src/app/organisms/settings/AuthRequest.scss [new file with mode: 0644]
src/app/organisms/settings/CrossSigning.jsx [new file with mode: 0644]
src/app/organisms/settings/CrossSigning.scss [new file with mode: 0644]
src/app/organisms/settings/DeviceManage.jsx
src/app/organisms/settings/KeyBackup.jsx [new file with mode: 0644]
src/app/organisms/settings/KeyBackup.scss [new file with mode: 0644]
src/app/organisms/settings/SecretStorageAccess.jsx [new file with mode: 0644]
src/app/organisms/settings/SecretStorageAccess.scss [new file with mode: 0644]
src/app/organisms/settings/Settings.jsx
src/client/initMatrix.js
src/client/state/secretStorageKeys.js [new file with mode: 0644]
src/index.scss
src/util/common.js
src/util/matrixUtil.js
webpack.common.js

diff --git a/src/app/atoms/card/InfoCard.jsx b/src/app/atoms/card/InfoCard.jsx
new file mode 100644 (file)
index 0000000..e530d5c
--- /dev/null
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import './InfoCard.scss';
+
+import Text from '../text/Text';
+import RawIcon from '../system-icons/RawIcon';
+import IconButton from '../button/IconButton';
+
+import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
+
+function InfoCard({
+  className, style,
+  variant, iconSrc,
+  title, content,
+  rounded, requestClose,
+}) {
+  const classes = [`info-card info-card--${variant}`];
+  if (rounded) classes.push('info-card--rounded');
+  if (className) classes.push(className);
+  return (
+    <div className={classes.join(' ')} style={style}>
+      {iconSrc && (
+        <div className="info-card__icon">
+          <RawIcon color={`var(--ic-${variant}-high)`} src={iconSrc} />
+        </div>
+      )}
+      <div className="info-card__content">
+        <Text>{title}</Text>
+        {content}
+      </div>
+      {requestClose && (
+        <IconButton src={CrossIC} variant={variant} onClick={requestClose} />
+      )}
+    </div>
+  );
+}
+
+InfoCard.defaultProps = {
+  className: null,
+  style: null,
+  variant: 'surface',
+  iconSrc: null,
+  content: null,
+  rounded: false,
+  requestClose: null,
+};
+
+InfoCard.propTypes = {
+  className: PropTypes.string,
+  style: PropTypes.shape({}),
+  variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
+  iconSrc: PropTypes.string,
+  title: PropTypes.string.isRequired,
+  content: PropTypes.node,
+  rounded: PropTypes.bool,
+  requestClose: PropTypes.func,
+};
+
+export default InfoCard;
diff --git a/src/app/atoms/card/InfoCard.scss b/src/app/atoms/card/InfoCard.scss
new file mode 100644 (file)
index 0000000..79d72eb
--- /dev/null
@@ -0,0 +1,79 @@
+@use '.././../partials/flex';
+@use '.././../partials/dir';
+
+.info-card {
+  display: flex;
+  align-items: flex-start;
+  line-height: 0;
+  padding: var(--sp-tight);
+  @include dir.prop(border-left, 4px solid transparent, none);
+  @include dir.prop(border-right, none, 4px solid transparent);
+
+  & > .ic-btn {
+    padding: 0;
+    border-radius: 4;
+  }
+
+  &__content {
+    margin: 0 var(--sp-tight);
+    @extend .cp-fx__item-one;
+
+    & > *:nth-child(2) {
+      margin-top: var(--sp-ultra-tight);
+    }
+  }
+
+  &--rounded {
+    @include dir.prop(
+      border-radius,
+      0 var(--bo-radius) var(--bo-radius) 0,
+      var(--bo-radius) 0 0 var(--bo-radius)
+    );
+  }
+
+  &--surface {
+    border-color: var(--bg-surface-border);
+    background-color: var(--bg-surface-hover);
+
+  }
+  &--primary {
+    border-color: var(--bg-primary);
+    background-color: var(--bg-primary-hover);
+    & .text {
+      color: var(--tc-primary-high);
+      &-b3 {
+        color: var(--tc-primary-normal);
+      }
+    }
+  }
+  &--positive {
+    border-color: var(--bg-positive-border);
+    background-color: var(--bg-positive-hover);
+    & .text {
+      color: var(--tc-positive-high);
+      &-b3 {
+        color: var(--tc-positive-normal);
+      }
+    }
+  }
+  &--caution {
+    border-color: var(--bg-caution-border);
+    background-color: var(--bg-caution-hover);
+    & .text {
+      color: var(--tc-caution-high);
+      &-b3 {
+        color: var(--tc-caution-normal);
+      }
+    }
+  }
+  &--danger {
+    border-color: var(--bg-danger-border);
+    background-color: var(--bg-danger-hover);
+    & .text {
+      color: var(--tc-danger-high);
+      &-b3 {
+        color: var(--tc-danger-normal);
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/app/hooks/useCrossSigningStatus.js b/src/app/hooks/useCrossSigningStatus.js
new file mode 100644 (file)
index 0000000..61b69d1
--- /dev/null
@@ -0,0 +1,25 @@
+/* eslint-disable import/prefer-default-export */
+import { useState, useEffect } from 'react';
+
+import initMatrix from '../../client/initMatrix';
+import { hasCrossSigningAccountData } from '../../util/matrixUtil';
+
+export function useCrossSigningStatus() {
+  const mx = initMatrix.matrixClient;
+  const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
+
+  useEffect(() => {
+    if (isCSEnabled) return null;
+    const handleAccountData = (event) => {
+      if (event.getType() === 'm.cross_signing.master') {
+        setIsCSEnabled(true);
+      }
+    };
+
+    mx.on('accountData', handleAccountData);
+    return () => {
+      mx.removeListener('accountData', handleAccountData);
+    };
+  }, [isCSEnabled === false]);
+  return isCSEnabled;
+}
diff --git a/src/app/hooks/useDeviceList.js b/src/app/hooks/useDeviceList.js
new file mode 100644 (file)
index 0000000..2cce0fe
--- /dev/null
@@ -0,0 +1,32 @@
+/* eslint-disable import/prefer-default-export */
+import { useState, useEffect } from 'react';
+
+import initMatrix from '../../client/initMatrix';
+
+export function useDeviceList() {
+  const mx = initMatrix.matrixClient;
+  const [deviceList, setDeviceList] = useState(null);
+
+  useEffect(() => {
+    let isMounted = true;
+
+    const updateDevices = () => mx.getDevices().then((data) => {
+      if (!isMounted) return;
+      setDeviceList(data.devices || []);
+    });
+    updateDevices();
+
+    const handleDevicesUpdate = (users) => {
+      if (users.includes(mx.getUserId())) {
+        updateDevices();
+      }
+    };
+
+    mx.on('crypto.devicesUpdated', handleDevicesUpdate);
+    return () => {
+      mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
+      isMounted = false;
+    };
+  }, []);
+  return deviceList;
+}
index 637766a9c854c122648bb9edd864f8fc447c3f31..a59520f6154acbb12487f26850350e0ed307d434 100644 (file)
@@ -37,7 +37,7 @@ function Dialog({
             {contentOptions}
           </Header>
           <div className="dialog__content__wrapper">
-            <ScrollView autoHide invisible={invisibleScroll}>
+            <ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
               <div className="dialog__content-container">
                 {children}
               </div>
index b05cafcfb94d0812ef8c493dff49d72f55469398..7340e119b1da377b2bf77a401bb3ca2e85adc6dd 100644 (file)
@@ -24,7 +24,7 @@ function ReusableDialog() {
   }, []);
 
   const handleAfterClose = () => {
-    data.afterClose();
+    data.afterClose?.();
     setData(null);
   };
 
index 7b2814549ca9a9dcd086556ea2de6fcb392b59db..bc8b7c8f243f066ce58a3fd136b4d6255e07f903 100644 (file)
@@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
 import { blurOnBubbling } from '../../atoms/button/script';
 
 const SidebarAvatar = React.forwardRef(({
-  tooltip, active, onClick, onContextMenu,
-  avatar, notificationBadge,
+  className, tooltip, active, onClick,
+  onContextMenu, avatar, notificationBadge,
 }, ref) => {
-  let activeClass = '';
-  if (active) activeClass = ' sidebar-avatar--active';
+  const classes = ['sidebar-avatar'];
+  if (active) classes.push('sidebar-avatar--active');
+  if (className) classes.push(className);
   return (
     <Tooltip
       content={<Text variant="b1">{twemojify(tooltip)}</Text>}
@@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({
     >
       <button
         ref={ref}
-        className={`sidebar-avatar${activeClass}`}
+        className={classes.join(' ')}
         type="button"
         onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
         onClick={onClick}
@@ -34,6 +35,7 @@ const SidebarAvatar = React.forwardRef(({
   );
 });
 SidebarAvatar.defaultProps = {
+  className: null,
   active: false,
   onClick: null,
   onContextMenu: null,
@@ -41,6 +43,7 @@ SidebarAvatar.defaultProps = {
 };
 
 SidebarAvatar.propTypes = {
+  className: PropTypes.string,
   tooltip: PropTypes.string.isRequired,
   active: PropTypes.bool,
   onClick: PropTypes.func,
index 1bc4553f9171c62a8be0196aba3c3d586dd7d715..0db59d9633e48f1a91c9e42509c08c61fd1ffc3f 100644 (file)
   }
 }
 
+.emoji-row {
+  display: flex;
+}
+
 .emoji-group {
   --emoji-padding: 6px;
   position: relative;
index 54723bddadd90ca9813ad7cd37b4fc25919b5032..eb20b72c654559a486f2e7f4c0c93d2125358b63 100644 (file)
@@ -14,6 +14,7 @@ import {
 } from '../../../client/action/navigation';
 import { moveSpaceShortcut } from '../../../client/action/accountData';
 import { abbreviateNumber, getEventCords } from '../../../util/common';
+import { isCrossVerified } from '../../../util/matrixUtil';
 
 import Avatar from '../../atoms/avatar/Avatar';
 import NotificationBadge from '../../atoms/badge/NotificationBadge';
@@ -26,8 +27,12 @@ import UserIC from '../../../../public/res/ic/outlined/user.svg';
 import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
 import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
+import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
 
 import { useSelectedTab } from '../../hooks/useSelectedTab';
+import { useDeviceList } from '../../hooks/useDeviceList';
+
+import { tabText as settingTabText } from '../settings/Settings';
 
 function useNotificationUpdate() {
   const { notifications } = initMatrix;
@@ -85,6 +90,22 @@ function ProfileAvatarMenu() {
   );
 }
 
+function CrossSigninAlert() {
+  const deviceList = useDeviceList();
+  const unverified = deviceList?.filter((device) => !isCrossVerified(device.device_id));
+
+  if (!unverified?.length) return null;
+
+  return (
+    <SidebarAvatar
+      className="sidebar__cross-signin-alert"
+      tooltip={`${unverified.length} unverified sessions`}
+      onClick={() => openSettings(settingTabText.SECURITY)}
+      avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
+    />
+  );
+}
+
 function FeaturedTab() {
   const { roomList, accountData, notifications } = initMatrix;
   const [selectedTab] = useSelectedTab();
@@ -358,6 +379,7 @@ function SideBar() {
               notificationBadge={<NotificationBadge alert content={totalInvites} />}
             />
           )}
+          <CrossSigninAlert />
           <ProfileAvatarMenu />
         </div>
       </div>
index 9f9ade726ef43385a7d2b48b592e449bff6d6945..6ff66288a52deb5a5ba7813cbe717a261a8bfe42 100644 (file)
   width: 24px;
   height: 1px;
   background-color: var(--bg-surface-border);
+}
+
+.sidebar__cross-signin-alert .avatar-container {
+  box-shadow: var(--bs-danger-border);
+  animation-name: pushRight;
+  animation-duration: 400ms;
+  animation-iteration-count: infinite;
+  animation-direction: alternate;
+}
+
+@keyframes pushRight {
+  from {
+    transform: translateX(4px) scale(1);
+  }
+  to {
+    transform: translateX(0) scale(1);
+  }
 }
\ No newline at end of file
diff --git a/src/app/organisms/settings/AuthRequest.jsx b/src/app/organisms/settings/AuthRequest.jsx
new file mode 100644 (file)
index 0000000..ca07c2a
--- /dev/null
@@ -0,0 +1,117 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import './AuthRequest.scss';
+
+import initMatrix from '../../../client/initMatrix';
+import { openReusableDialog } from '../../../client/action/navigation';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+
+import { useStore } from '../../hooks/useStore';
+
+let lastUsedPassword;
+const getAuthId = (password) => ({
+  type: 'm.login.password',
+  password,
+  identifier: {
+    type: 'm.id.user',
+    user: initMatrix.matrixClient.getUserId(),
+  },
+});
+
+function AuthRequest({ onComplete, makeRequest }) {
+  const [status, setStatus] = useState(false);
+  const mountStore = useStore();
+
+  const handleForm = async (e) => {
+    mountStore.setItem(true);
+    e.preventDefault();
+    const password = e.target.password.value;
+    if (password.trim() === '') return;
+    try {
+      setStatus({ ongoing: true });
+      await makeRequest(getAuthId(password));
+      lastUsedPassword = password;
+      if (!mountStore.getItem()) return;
+      onComplete(true);
+    } catch (err) {
+      lastUsedPassword = undefined;
+      if (!mountStore.getItem()) return;
+      if (err.errcode === 'M_FORBIDDEN') {
+        setStatus({ error: 'Wrong password. Please enter correct password.' });
+        return;
+      }
+      setStatus({ error: 'Request failed!' });
+    }
+  };
+
+  const handleChange = () => {
+    setStatus(false);
+  };
+
+  return (
+    <div className="auth-request">
+      <form onSubmit={handleForm}>
+        <Input
+          name="password"
+          label="Account password"
+          type="password"
+          onChange={handleChange}
+          required
+        />
+        {status.ongoing && <Spinner size="small" />}
+        {status.error && <Text variant="b3">{status.error}</Text>}
+        {(status === false || status.error) && <Button variant="primary" type="submit" disabled={!!status.error}>Continue</Button>}
+      </form>
+    </div>
+  );
+}
+AuthRequest.propTypes = {
+  onComplete: PropTypes.func.isRequired,
+  makeRequest: PropTypes.func.isRequired,
+};
+
+/**
+ * @param {string} title Title of dialog
+ * @param {(auth) => void} makeRequest request to make
+ * @returns {Promise<boolean>} whether the request succeed or not.
+ */
+export const authRequest = async (title, makeRequest) => {
+  try {
+    const auth = lastUsedPassword ? getAuthId(lastUsedPassword) : undefined;
+    await makeRequest(auth);
+    return true;
+  } catch (e) {
+    lastUsedPassword = undefined;
+    if (e.httpStatus !== 401 || e.data?.flows === undefined) return false;
+
+    const { flows } = e.data;
+    const canUsePassword = flows.find((f) => f.stages.includes('m.login.password'));
+    if (!canUsePassword) return false;
+
+    return new Promise((resolve) => {
+      let isCompleted = false;
+      openReusableDialog(
+        <Text variant="s1" weight="medium">{title}</Text>,
+        (requestClose) => (
+          <AuthRequest
+            onComplete={(done) => {
+              isCompleted = true;
+              resolve(done);
+              requestClose();
+            }}
+            makeRequest={makeRequest}
+          />
+        ),
+        () => {
+          if (!isCompleted) resolve(false);
+        },
+      );
+    });
+  }
+};
+
+export default AuthRequest;
diff --git a/src/app/organisms/settings/AuthRequest.scss b/src/app/organisms/settings/AuthRequest.scss
new file mode 100644 (file)
index 0000000..35e95bf
--- /dev/null
@@ -0,0 +1,12 @@
+.auth-request {
+  padding: var(--sp-normal);
+
+  & form > *:not(:first-child) {
+    margin-top: var(--sp-normal);
+  }
+
+  & .text-b3 {
+    color: var(--tc-danger-high);
+    margin-top: var(--sp-ultra-tight) !important;
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/settings/CrossSigning.jsx b/src/app/organisms/settings/CrossSigning.jsx
new file mode 100644 (file)
index 0000000..9213e9d
--- /dev/null
@@ -0,0 +1,223 @@
+/* eslint-disable react/jsx-one-expression-per-line */
+import React, { useState } from 'react';
+import './CrossSigning.scss';
+import FileSaver from 'file-saver';
+import { Formik } from 'formik';
+import { twemojify } from '../../../util/twemojify';
+
+import initMatrix from '../../../client/initMatrix';
+import { openReusableDialog } from '../../../client/action/navigation';
+import { copyToClipboard } from '../../../util/common';
+import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+
+import { authRequest } from './AuthRequest';
+import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
+
+const failedDialog = () => {
+  const renderFailure = (requestClose) => (
+    <div className="cross-signing__failure">
+      <Text variant="h1">{twemojify('❌')}</Text>
+      <Text weight="medium">Failed to setup cross signing. Please try again.</Text>
+      <Button onClick={requestClose}>Close</Button>
+    </div>
+  );
+
+  openReusableDialog(
+    <Text variant="s1" weight="medium">Setup cross signing</Text>,
+    renderFailure,
+  );
+};
+
+const securityKeyDialog = (key) => {
+  const downloadKey = () => {
+    const blob = new Blob([key.encodedPrivateKey], {
+      type: 'text/plain;charset=us-ascii',
+    });
+    FileSaver.saveAs(blob, 'security-key.txt');
+  };
+  const copyKey = () => {
+    copyToClipboard(key.encodedPrivateKey);
+  };
+
+  const renderSecurityKey = () => (
+    <div className="cross-signing__key">
+      <Text weight="medium">Please save this security key somewhere safe.</Text>
+      <Text className="cross-signing__key-text">
+        {key.encodedPrivateKey}
+      </Text>
+      <div className="cross-signing__key-btn">
+        <Button variant="primary" onClick={() => copyKey(key)}>Copy</Button>
+        <Button onClick={() => downloadKey(key)}>Download</Button>
+      </div>
+    </div>
+  );
+
+  // Download automatically.
+  downloadKey();
+
+  openReusableDialog(
+    <Text variant="s1" weight="medium">Security Key</Text>,
+    () => renderSecurityKey(),
+  );
+};
+
+function CrossSigningSetup() {
+  const initialValues = { phrase: '', confirmPhrase: '' };
+  const [genWithPhrase, setGenWithPhrase] = useState(undefined);
+
+  const setup = async (securityPhrase = undefined) => {
+    const mx = initMatrix.matrixClient;
+    setGenWithPhrase(typeof securityPhrase === 'string');
+    const recoveryKey = await mx.createRecoveryKeyFromPassphrase(securityPhrase);
+    clearSecretStorageKeys();
+
+    await mx.bootstrapSecretStorage({
+      createSecretStorageKey: async () => recoveryKey,
+      setupNewKeyBackup: true,
+      setupNewSecretStorage: true,
+    });
+
+    const authUploadDeviceSigningKeys = async (makeRequest) => {
+      const isDone = await authRequest('Setup cross signing', async (auth) => {
+        await makeRequest(auth);
+      });
+      setTimeout(() => {
+        if (isDone) securityKeyDialog(recoveryKey);
+        else failedDialog();
+      });
+    };
+
+    await mx.bootstrapCrossSigning({
+      authUploadDeviceSigningKeys,
+      setupNewCrossSigning: true,
+    });
+  };
+
+  const validator = (values) => {
+    const errors = {};
+    if (values.phrase === '12345678') {
+      errors.phrase = 'How about 87654321 ?';
+    }
+    if (values.phrase === '87654321') {
+      errors.phrase = 'Your are playing with 🔥';
+    }
+    const PHRASE_REGEX = /^([^\s]){8,127}$/;
+    if (values.phrase.length > 0 && !PHRASE_REGEX.test(values.phrase)) {
+      errors.phrase = 'Phrase must contain 8-127 characters with no space.';
+    }
+    if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
+      errors.confirmPhrase = 'Phrase don\'t match.';
+    }
+    return errors;
+  };
+
+  return (
+    <div className="cross-signing__setup">
+      <div className="cross-signing__setup-entry">
+        <Text>
+          We will generate a <b>Security Key</b>, 
+          which you can use to manage messages backup and session verification.
+        </Text>
+        {genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
+        {genWithPhrase === false && <Spinner size="small" />}
+      </div>
+      <Text className="cross-signing__setup-divider">OR</Text>
+      <Formik
+        initialValues={initialValues}
+        onSubmit={(values) => setup(values.phrase)}
+        validate={validator}
+      >
+        {({
+          values, errors, handleChange, handleSubmit,
+        }) => (
+          <form
+            className="cross-signing__setup-entry"
+            onSubmit={handleSubmit}
+            disabled={genWithPhrase !== undefined}
+          >
+            <Text>
+              Alternatively you can also set a <b>Security Phrase </b>
+              so you don't have to remember long Security Key, 
+              and optionally save the Key as backup.
+            </Text>
+            <Input
+              name="phrase"
+              value={values.phrase}
+              onChange={handleChange}
+              label="Security Phrase"
+              type="password"
+              required
+              disabled={genWithPhrase !== undefined}
+            />
+            {errors.phrase && <Text variant="b3" className="cross-signing__error">{errors.phrase}</Text>}
+            <Input
+              name="confirmPhrase"
+              value={values.confirmPhrase}
+              onChange={handleChange}
+              label="Confirm Security Phrase"
+              type="password"
+              required
+              disabled={genWithPhrase !== undefined}
+            />
+            {errors.confirmPhrase && <Text variant="b3" className="cross-signing__error">{errors.confirmPhrase}</Text>}
+            {genWithPhrase !== true && <Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>Set Phrase & Generate Key</Button>}
+            {genWithPhrase === true && <Spinner size="small" />}
+          </form>
+        )}
+      </Formik>
+    </div>
+  );
+}
+
+const setupDialog = () => {
+  openReusableDialog(
+    <Text variant="s1" weight="medium">Setup cross signing</Text>,
+    () => <CrossSigningSetup />,
+  );
+};
+
+function CrossSigningReset() {
+  return (
+    <div className="cross-signing__reset">
+      <Text variant="h1">{twemojify('✋🧑‍🚒🤚')}</Text>
+      <Text weight="medium">Resetting cross-signing keys is permanent.</Text>
+      <Text>
+        Anyone you have verified with will see security alerts and your message backup will lost. 
+        You almost certainly do not want to do this, 
+        unless you have lost <b>Security Key</b> or <b>Phrase</b> and 
+        every session you can cross-sign from.
+      </Text>
+      <Button variant="danger" onClick={setupDialog}>Reset</Button>
+    </div>
+  );
+}
+
+const resetDialog = () => {
+  openReusableDialog(
+    <Text variant="s1" weight="medium">Reset cross signing</Text>,
+    () => <CrossSigningReset />,
+  );
+};
+
+function CrossSignin() {
+  const isCSEnabled = useCrossSigningStatus();
+  return (
+    <SettingTile
+      title="Cross signing"
+      content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
+      options={(
+        isCSEnabled
+          ? <Button variant="danger" onClick={resetDialog}>Reset</Button>
+          : <Button variant="primary" onClick={setupDialog}>Setup</Button>
+      )}
+    />
+  );
+}
+
+export default CrossSignin;
diff --git a/src/app/organisms/settings/CrossSigning.scss b/src/app/organisms/settings/CrossSigning.scss
new file mode 100644 (file)
index 0000000..b4b606d
--- /dev/null
@@ -0,0 +1,55 @@
+.cross-signing {
+  &__setup {
+    padding: var(--sp-normal);
+  }
+  &__setup-entry {
+    & > *:not(:first-child) {
+      margin-top: var(--sp-normal);
+    }
+  }
+
+  &__error {
+    color: var(--tc-danger-high);
+    margin-top: var(--sp-ultra-tight) !important;
+  }
+
+  &__setup-divider {
+    margin: var(--sp-tight) 0;
+    display: flex;
+    align-items: center;
+  
+    &::before,
+    &::after {
+      flex: 1;
+      content: '';
+      margin: var(--sp-tight) 0;
+      border-bottom: 1px solid var(--bg-surface-border);
+    }
+  }
+}
+
+.cross-signing__key {
+  padding: var(--sp-normal);
+
+  &-text {
+    margin: var(--sp-normal) 0;
+    padding: var(--sp-extra-tight);
+    background-color: var(--bg-surface-low);
+    border-radius: var(--bo-radius);
+  }
+  &-btn {
+    display: flex;
+    & > button:last-child {
+      margin: 0 var(--sp-normal);
+    }
+  }
+}
+
+.cross-signing__failure,
+.cross-signing__reset {
+  padding: var(--sp-normal);
+  padding-top: var(--sp-extra-loose);
+  & > .text {
+    padding-bottom: var(--sp-normal);
+  }
+}
\ No newline at end of file
index 6beb2dc612d8cc360cf1e5094a3828064aaa7a6b..5c60bf0a9427e2a682c07c7b8bfc7ebade19989a 100644 (file)
@@ -3,62 +3,30 @@ import './DeviceManage.scss';
 import dateFormat from 'dateformat';
 
 import initMatrix from '../../../client/initMatrix';
+import { isCrossVerified } from '../../../util/matrixUtil';
 
 import Text from '../../atoms/text/Text';
 import Button from '../../atoms/button/Button';
 import IconButton from '../../atoms/button/IconButton';
 import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
+import InfoCard from '../../atoms/card/InfoCard';
 import Spinner from '../../atoms/spinner/Spinner';
 import SettingTile from '../../molecules/setting-tile/SettingTile';
 
 import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
 import BinIC from '../../../../public/res/ic/outlined/bin.svg';
+import InfoIC from '../../../../public/res/ic/outlined/info.svg';
 
-import { useStore } from '../../hooks/useStore';
-
-function useDeviceList() {
-  const mx = initMatrix.matrixClient;
-  const [deviceList, setDeviceList] = useState(null);
-
-  useEffect(() => {
-    let isMounted = true;
-
-    const updateDevices = () => mx.getDevices().then((data) => {
-      if (!isMounted) return;
-      setDeviceList(data.devices || []);
-    });
-    updateDevices();
-
-    const handleDevicesUpdate = (users) => {
-      if (users.includes(mx.getUserId())) {
-        updateDevices();
-      }
-    };
-
-    mx.on('crypto.devicesUpdated', handleDevicesUpdate);
-    return () => {
-      mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
-      isMounted = false;
-    };
-  }, []);
-  return deviceList;
-}
+import { authRequest } from './AuthRequest';
 
-function isCrossVerified(deviceId) {
-  try {
-    const mx = initMatrix.matrixClient;
-    const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());
-    const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
-    const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
-    return deviceTrust.isCrossSigningVerified();
-  } catch {
-    return false;
-  }
-}
+import { useStore } from '../../hooks/useStore';
+import { useDeviceList } from '../../hooks/useDeviceList';
+import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
 
 function DeviceManage() {
   const TRUNCATED_COUNT = 4;
   const mx = initMatrix.matrixClient;
+  const isCSEnabled = useCrossSigningStatus();
   const deviceList = useDeviceList();
   const [processing, setProcessing] = useState([]);
   const [truncated, setTruncated] = useState(true);
@@ -105,38 +73,15 @@ function DeviceManage() {
     }
   };
 
-  const handleRemove = async (device, auth = undefined) => {
-    if (auth === undefined
-      ? window.confirm(`You are about to logout "${device.display_name}" session.`)
-      : true
-    ) {
+  const handleRemove = async (device) => {
+    if (window.confirm(`You are about to logout "${device.display_name}" session.`)) {
       addToProcessing(device);
-      try {
+      await authRequest(`Logout "${device.display_name}"`, async (auth) => {
         await mx.deleteDevice(device.device_id, auth);
-      } catch (e) {
-        if (e.httpStatus === 401 && e.data?.flows) {
-          const { flows } = e.data;
-          const flow = flows.find((f) => f.stages.includes('m.login.password'));
-          if (flow) {
-            const password = window.prompt('Please enter account password', '');
-            if (password && password.trim() !== '') {
-              handleRemove(device, {
-                session: e.data.session,
-                type: 'm.login.password',
-                password,
-                identifier: {
-                  type: 'm.id.user',
-                  user: mx.getUserId(),
-                },
-              });
-              return;
-            }
-          }
-        }
-        window.alert('Failed to remove session!');
-        if (!mountStore.getItem()) return;
-        removeFromProcessing(device);
-      }
+      });
+
+      if (!mountStore.getItem()) return;
+      removeFromProcessing(device);
     }
   };
 
@@ -187,6 +132,16 @@ function DeviceManage() {
     <div className="device-manage">
       <div>
         <MenuHeader>Unverified sessions</MenuHeader>
+        {!isCSEnabled && (
+          <div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
+            <InfoCard
+              rounded
+              variant="caution"
+              iconSrc={InfoIC}
+              title="Setup cross signing in case you lose all your sessions."
+            />
+          </div>
+        )}
         {
           unverified.length > 0
             ? unverified.map((device) => renderDevice(device, false))
diff --git a/src/app/organisms/settings/KeyBackup.jsx b/src/app/organisms/settings/KeyBackup.jsx
new file mode 100644 (file)
index 0000000..5d2f4ed
--- /dev/null
@@ -0,0 +1,288 @@
+/* eslint-disable react/prop-types */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import './KeyBackup.scss';
+import { twemojify } from '../../../util/twemojify';
+
+import initMatrix from '../../../client/initMatrix';
+import { openReusableDialog } from '../../../client/action/navigation';
+import { deletePrivateKey } from '../../../client/state/secretStorageKeys';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import IconButton from '../../atoms/button/IconButton';
+import Spinner from '../../atoms/spinner/Spinner';
+import InfoCard from '../../atoms/card/InfoCard';
+import SettingTile from '../../molecules/setting-tile/SettingTile';
+
+import { accessSecretStorage } from './SecretStorageAccess';
+
+import InfoIC from '../../../../public/res/ic/outlined/info.svg';
+import BinIC from '../../../../public/res/ic/outlined/bin.svg';
+import DownloadIC from '../../../../public/res/ic/outlined/download.svg';
+
+import { useStore } from '../../hooks/useStore';
+import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
+
+function CreateKeyBackupDialog({ keyData }) {
+  const [done, setDone] = useState(false);
+  const mx = initMatrix.matrixClient;
+  const mountStore = useStore();
+
+  const doBackup = async () => {
+    setDone(false);
+    let info;
+
+    try {
+      info = await mx.prepareKeyBackupVersion(
+        null,
+        { secureSecretStorage: true },
+      );
+      info = await mx.createKeyBackupVersion(info);
+      await mx.scheduleAllGroupSessionsForBackup();
+      if (!mountStore.getItem()) return;
+      setDone(true);
+    } catch (e) {
+      deletePrivateKey(keyData.keyId);
+      await mx.deleteKeyBackupVersion(info.version);
+      if (!mountStore.getItem()) return;
+      setDone(null);
+    }
+  };
+
+  useEffect(() => {
+    mountStore.setItem(true);
+    doBackup();
+  }, []);
+
+  return (
+    <div className="key-backup__create">
+      {done === false && (
+        <div>
+          <Spinner size="small" />
+          <Text>Creating backup...</Text>
+        </div>
+      )}
+      {done === true && (
+        <>
+          <Text variant="h1">{twemojify('✅')}</Text>
+          <Text>Successfully created backup</Text>
+        </>
+      )}
+      {done === null && (
+        <>
+          <Text>Failed to create backup</Text>
+          <Button onClick={doBackup}>Retry</Button>
+        </>
+      )}
+    </div>
+  );
+}
+CreateKeyBackupDialog.propTypes = {
+  keyData: PropTypes.shape({}).isRequired,
+};
+
+function RestoreKeyBackupDialog({ keyData }) {
+  const [status, setStatus] = useState(false);
+  const mx = initMatrix.matrixClient;
+  const mountStore = useStore();
+
+  const restoreBackup = async () => {
+    setStatus(false);
+
+    let meBreath = true;
+    const progressCallback = (progress) => {
+      if (!progress.successes) return;
+      if (meBreath === false) return;
+      meBreath = false;
+      setTimeout(() => {
+        meBreath = true;
+      }, 200);
+
+      setStatus({ message: `Restoring backup keys... (${progress.successes}/${progress.total})` });
+    };
+
+    try {
+      const backupInfo = await mx.getKeyBackupVersion();
+      const info = await mx.restoreKeyBackupWithSecretStorage(
+        backupInfo,
+        undefined,
+        undefined,
+        { progressCallback },
+      );
+      if (!mountStore.getItem()) return;
+      setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
+    } catch (e) {
+      if (!mountStore.getItem()) return;
+      if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
+        deletePrivateKey(keyData.keyId);
+        setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
+      } else {
+        setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
+      }
+    }
+  };
+
+  useEffect(() => {
+    mountStore.setItem(true);
+    restoreBackup();
+  }, []);
+
+  return (
+    <div className="key-backup__restore">
+      {(status === false || status.message) && (
+        <div>
+          <Spinner size="small" />
+          <Text>{status.message ?? 'Restoring backup keys...'}</Text>
+        </div>
+      )}
+      {status.done && (
+        <>
+          <Text variant="h1">{twemojify('✅')}</Text>
+          <Text>{status.done}</Text>
+        </>
+      )}
+      {status.error && (
+        <>
+          <Text>{status.error}</Text>
+          <Button onClick={restoreBackup}>Retry</Button>
+        </>
+      )}
+    </div>
+  );
+}
+RestoreKeyBackupDialog.propTypes = {
+  keyData: PropTypes.shape({}).isRequired,
+};
+
+function DeleteKeyBackupDialog({ requestClose }) {
+  const [isDeleting, setIsDeleting] = useState(false);
+  const mx = initMatrix.matrixClient;
+  const mountStore = useStore();
+  mountStore.setItem(true);
+
+  const deleteBackup = async () => {
+    setIsDeleting(true);
+    try {
+      const backupInfo = await mx.getKeyBackupVersion();
+      if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
+      if (!mountStore.getItem()) return;
+      requestClose(true);
+    } catch {
+      if (!mountStore.getItem()) return;
+      setIsDeleting(false);
+    }
+  };
+
+  return (
+    <div className="key-backup__delete">
+      <Text variant="h1">{twemojify('🗑')}</Text>
+      <Text weight="medium">Deleting key backup is permanent.</Text>
+      <Text>All encrypted messages keys stored on server will be deleted.</Text>
+      {
+        isDeleting
+          ? <Spinner size="small" />
+          : <Button variant="danger" onClick={deleteBackup}>Delete</Button>
+      }
+    </div>
+  );
+}
+DeleteKeyBackupDialog.propTypes = {
+  requestClose: PropTypes.func.isRequired,
+};
+
+function KeyBackup() {
+  const mx = initMatrix.matrixClient;
+  const isCSEnabled = useCrossSigningStatus();
+  const [keyBackup, setKeyBackup] = useState(undefined);
+  const mountStore = useStore();
+
+  const fetchKeyBackupVersion = async () => {
+    const info = await mx.getKeyBackupVersion();
+    if (!mountStore.getItem()) return;
+    setKeyBackup(info);
+  };
+
+  useEffect(() => {
+    mountStore.setItem(true);
+    fetchKeyBackupVersion();
+
+    const handleAccountData = (event) => {
+      if (event.getType() === 'm.megolm_backup.v1') {
+        fetchKeyBackupVersion();
+      }
+    };
+
+    mx.on('accountData', handleAccountData);
+    return () => {
+      mx.removeListener('accountData', handleAccountData);
+    };
+  }, [isCSEnabled]);
+
+  const openCreateKeyBackup = async () => {
+    const keyData = await accessSecretStorage('Create Key Backup');
+    if (keyData === null) return;
+
+    openReusableDialog(
+      <Text variant="s1" weight="medium">Create Key Backup</Text>,
+      () => <CreateKeyBackupDialog keyData={keyData} />,
+      () => fetchKeyBackupVersion(),
+    );
+  };
+
+  const openRestoreKeyBackup = async () => {
+    const keyData = await accessSecretStorage('Restore Key Backup');
+    if (keyData === null) return;
+
+    openReusableDialog(
+      <Text variant="s1" weight="medium">Restore Key Backup</Text>,
+      () => <RestoreKeyBackupDialog keyData={keyData} />,
+    );
+  };
+
+  const openDeleteKeyBackup = () => openReusableDialog(
+    <Text variant="s1" weight="medium">Delete Key Backup</Text>,
+    (requestClose) => (
+      <DeleteKeyBackupDialog
+        requestClose={(isDone) => {
+          if (isDone) setKeyBackup(null);
+          requestClose();
+        }}
+      />
+    ),
+  );
+
+  const renderOptions = () => {
+    if (keyBackup === undefined) return <Spinner size="small" />;
+    if (keyBackup === null) return <Button variant="primary" onClick={openCreateKeyBackup}>Create Backup</Button>;
+    return (
+      <>
+        <IconButton src={DownloadIC} variant="positive" onClick={openRestoreKeyBackup} tooltip="Restore backup" />
+        <IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
+      </>
+    );
+  };
+
+  return (
+    <SettingTile
+      title="Encrypted messages backup"
+      content={(
+        <>
+          <Text variant="b3">Online backup your encrypted messages keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.</Text>
+          {!isCSEnabled && (
+            <InfoCard
+              style={{ marginTop: 'var(--sp-ultra-tight)' }}
+              rounded
+              variant="caution"
+              iconSrc={InfoIC}
+              title="Setup cross signing to backup your encrypted messages."
+            />
+          )}
+        </>
+      )}
+      options={isCSEnabled ? renderOptions() : null}
+    />
+  );
+}
+
+export default KeyBackup;
diff --git a/src/app/organisms/settings/KeyBackup.scss b/src/app/organisms/settings/KeyBackup.scss
new file mode 100644 (file)
index 0000000..1f2b9b6
--- /dev/null
@@ -0,0 +1,27 @@
+.key-backup__create,
+.key-backup__restore {
+  padding: var(--sp-normal);
+
+  & > div {
+    padding: var(--sp-normal) 0;
+    display: flex;
+    align-items: center;
+  
+    & > .text {
+      margin: 0 var(--sp-normal);
+    }
+  }
+
+  & > .text {
+    margin-bottom: var(--sp-normal);
+  }
+}
+
+.key-backup__delete {
+  padding: var(--sp-normal);
+  padding-top: var(--sp-extra-loose);
+
+  & > .text {
+    padding-bottom: var(--sp-normal);
+  }
+}
\ No newline at end of file
diff --git a/src/app/organisms/settings/SecretStorageAccess.jsx b/src/app/organisms/settings/SecretStorageAccess.jsx
new file mode 100644 (file)
index 0000000..f0131b1
--- /dev/null
@@ -0,0 +1,133 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import './SecretStorageAccess.scss';
+import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
+
+import initMatrix from '../../../client/initMatrix';
+import { openReusableDialog } from '../../../client/action/navigation';
+import { getDefaultSSKey, getSSKeyInfo } from '../../../util/matrixUtil';
+import { storePrivateKey, hasPrivateKey, getPrivateKey } from '../../../client/state/secretStorageKeys';
+
+import Text from '../../atoms/text/Text';
+import Button from '../../atoms/button/Button';
+import Input from '../../atoms/input/Input';
+import Spinner from '../../atoms/spinner/Spinner';
+
+import { useStore } from '../../hooks/useStore';
+
+function SecretStorageAccess({ onComplete }) {
+  const mx = initMatrix.matrixClient;
+  const sSKeyId = getDefaultSSKey();
+  const sSKeyInfo = getSSKeyInfo(sSKeyId);
+  const isPassphrase = !!sSKeyInfo.passphrase;
+  const [withPhrase, setWithPhrase] = useState(isPassphrase);
+  const [process, setProcess] = useState(false);
+  const [error, setError] = useState(null);
+  const mountStore = useStore();
+  mountStore.setItem(true);
+
+  const toggleWithPhrase = () => setWithPhrase(!withPhrase);
+
+  const processInput = async ({ key, phrase }) => {
+    setProcess(true);
+    try {
+      const { salt, iterations } = sSKeyInfo.passphrase;
+      const privateKey = key
+        ? mx.keyBackupKeyFromRecoveryKey(key)
+        : await deriveKey(phrase, salt, iterations);
+      const isCorrect = await mx.checkSecretStorageKey(privateKey, sSKeyInfo);
+
+      if (!mountStore.getItem()) return;
+      if (!isCorrect) {
+        setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
+        setProcess(false);
+        return;
+      }
+      onComplete({
+        keyId: sSKeyId,
+        key,
+        phrase,
+        privateKey,
+      });
+    } catch (e) {
+      if (!mountStore.getItem()) return;
+      setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
+      setProcess(false);
+    }
+  };
+
+  const handleForm = async (e) => {
+    e.preventDefault();
+    const password = e.target.password.value;
+    if (password.trim() === '') return;
+    const data = {};
+    if (withPhrase) data.phrase = password;
+    else data.key = password;
+    processInput(data);
+  };
+
+  const handleChange = () => {
+    setError(null);
+    setProcess(false);
+  };
+
+  return (
+    <div className="secret-storage-access">
+      <form onSubmit={handleForm}>
+        <Input
+          name="password"
+          label={`Security ${withPhrase ? 'Phrase' : 'Key'}`}
+          type="password"
+          onChange={handleChange}
+          required
+        />
+        {error && <Text variant="b3">{error}</Text>}
+        {!process && (
+          <div className="secret-storage-access__btn">
+            <Button variant="primary" type="submit">Continue</Button>
+            {isPassphrase && <Button onClick={toggleWithPhrase}>{`Use Security ${withPhrase ? 'Key' : 'Phrase'}`}</Button>}
+          </div>
+        )}
+      </form>
+      {process && <Spinner size="small" />}
+    </div>
+  );
+}
+SecretStorageAccess.propTypes = {
+  onComplete: PropTypes.func.isRequired,
+};
+
+/**
+ * @param {string} title Title of secret storage access dialog
+ * @returns {Promise<keyData | null>} resolve to keyData or null
+ */
+export const accessSecretStorage = (title) => new Promise((resolve) => {
+  let isCompleted = false;
+  const defaultSSKey = getDefaultSSKey();
+  if (hasPrivateKey(defaultSSKey)) {
+    resolve({ keyId: defaultSSKey, privateKey: getPrivateKey(defaultSSKey) });
+    return;
+  }
+  const handleComplete = (keyData) => {
+    isCompleted = true;
+    storePrivateKey(keyData.keyId, keyData.privateKey);
+    resolve(keyData);
+  };
+
+  openReusableDialog(
+    <Text variant="s1" weight="medium">{title}</Text>,
+    (requestClose) => (
+      <SecretStorageAccess
+        onComplete={(keyData) => {
+          handleComplete(keyData);
+          requestClose(requestClose);
+        }}
+      />
+    ),
+    () => {
+      if (!isCompleted) resolve(null);
+    },
+  );
+});
+
+export default SecretStorageAccess;
diff --git a/src/app/organisms/settings/SecretStorageAccess.scss b/src/app/organisms/settings/SecretStorageAccess.scss
new file mode 100644 (file)
index 0000000..a7c0a9f
--- /dev/null
@@ -0,0 +1,20 @@
+.secret-storage-access {
+  padding: var(--sp-normal);
+
+  & form > *:not(:first-child) {
+    margin-top: var(--sp-normal);
+  }
+
+  & .text-b3 {
+    color: var(--tc-danger-high);
+    margin-top: var(--sp-ultra-tight) !important;
+  }
+
+  &__btn {
+    display: flex;
+    justify-content: space-between;
+  }
+  & .donut-spinner {
+    margin-top: var(--sp-normal);
+  }
+}
\ No newline at end of file
index acfef5c9de4d212d00c70742228095ae6b59af75..6dbbffb2c515c3e1e29cf61dda658ec5c2a8ae36 100644 (file)
@@ -26,6 +26,8 @@ import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/Impor
 import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
 
 import ProfileEditor from '../profile-editor/ProfileEditor';
+import CrossSigning from './CrossSigning';
+import KeyBackup from './KeyBackup';
 import DeviceManage from './DeviceManage';
 
 import SunIC from '../../../../public/res/ic/outlined/sun.svg';
@@ -168,18 +170,13 @@ function SecuritySection() {
   return (
     <div className="settings-security">
       <div className="settings-security__card">
-        <MenuHeader>Session Info</MenuHeader>
-        <SettingTile
-          title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
-        />
-        <SettingTile
-          title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
-          content={<Text variant="b3">Use this session ID-key combo to verify or manage this session.</Text>}
-        />
+        <MenuHeader>Cross signing and backup</MenuHeader>
+        <CrossSigning />
+        <KeyBackup />
       </div>
       <DeviceManage />
       <div className="settings-security__card">
-        <MenuHeader>Encryption</MenuHeader>
+        <MenuHeader>Export/Import encryption keys</MenuHeader>
         <SettingTile
           title="Export E2E room keys"
           content={(
@@ -247,7 +244,7 @@ function AboutSection() {
   );
 }
 
-const tabText = {
+export const tabText = {
   APPEARANCE: 'Appearance',
   NOTIFICATIONS: 'Notifications',
   SECURITY: 'Security',
index f6fc9eb2a6116eb904aecd51a74f338b9b5494db..0e5cd0d05a70b3761fed60af2f0df44d89799e03 100644 (file)
@@ -7,6 +7,7 @@ import RoomList from './state/RoomList';
 import AccountData from './state/AccountData';
 import RoomsInput from './state/RoomsInput';
 import Notifications from './state/Notifications';
+import { cryptoCallbacks } from './state/secretStorageKeys';
 
 global.Olm = require('@matrix-org/olm');
 
@@ -36,6 +37,7 @@ class InitMatrix extends EventEmitter {
       cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
       deviceId: secret.deviceId,
       timelineSupport: true,
+      cryptoCallbacks,
     });
 
     await this.matrixClient.initCrypto();
diff --git a/src/client/state/secretStorageKeys.js b/src/client/state/secretStorageKeys.js
new file mode 100644 (file)
index 0000000..7439e7a
--- /dev/null
@@ -0,0 +1,41 @@
+const secretStorageKeys = new Map();
+
+export function storePrivateKey(keyId, privateKey) {
+  if (privateKey instanceof Uint8Array === false) {
+    throw new Error('Unable to store, privateKey is invalid.');
+  }
+  secretStorageKeys.set(keyId, privateKey);
+}
+
+export function hasPrivateKey(keyId) {
+  return secretStorageKeys.get(keyId) instanceof Uint8Array;
+}
+
+export function getPrivateKey(keyId) {
+  return secretStorageKeys.get(keyId);
+}
+
+export function deletePrivateKey(keyId) {
+  delete secretStorageKeys.delete(keyId);
+}
+
+export function clearSecretStorageKeys() {
+  secretStorageKeys.clear();
+}
+
+async function getSecretStorageKey({ keys }) {
+  const keyIds = Object.keys(keys);
+  const keyId = keyIds.find(hasPrivateKey);
+  if (!keyId) return undefined;
+  const privateKey = getPrivateKey(keyId);
+  return [keyId, privateKey];
+}
+
+function cacheSecretStorageKey(keyId, keyInfo, privateKey) {
+  secretStorageKeys.set(keyId, privateKey);
+}
+
+export const cryptoCallbacks = {
+  getSecretStorageKey,
+  cacheSecretStorageKey,
+};
index a073f7e7ea9dba023834e7a5597a47f4e36795c6..0276feae0d90d5a148c234e4c30fd2132e233054 100644 (file)
   --ic-surface-high: #272727;
   --ic-surface-normal: #626262;
   --ic-surface-low: #7c7c7c;
+  --ic-primary-high: #ffffff;
   --ic-primary-normal: #ffffff;
+  --ic-positive-high: rgba(69, 184, 59);
   --ic-positive-normal: rgba(69, 184, 59, 80%);
+  --ic-caution-high: rgba(255, 179, 0);
   --ic-caution-normal: rgba(255, 179, 0, 80%);
+  --ic-danger-high: rgba(240, 71, 71);
   --ic-danger-normal: rgba(240, 71, 71, 0.7);
 
   /* user mxid colors */
index 941f34cfe533df2ec7907715a2393e6924f8357c..3d5383ada6dd78a5b853e04dbc1271615d5e78f9 100644 (file)
@@ -114,3 +114,21 @@ export function getScrollInfo(target) {
 export function avatarInitials(text) {
   return [...text][0];
 }
+
+export function copyToClipboard(text) {
+  if (navigator.clipboard) {
+    navigator.clipboard.writeText(text);
+  } else {
+    const host = document.body;
+    const copyInput = document.createElement('input');
+    copyInput.style.position = 'fixed';
+    copyInput.style.opacity = '0';
+    copyInput.value = text;
+    host.append(copyInput);
+
+    copyInput.select();
+    copyInput.setSelectionRange(0, 99999);
+    document.execCommand('Copy');
+    copyInput.remove();
+  }
+}
index 1af6cb1936e06674621d8d0c8015f7594f322656..1b67f7e682c6ff5137890399698ea44289143e0a 100644 (file)
@@ -162,3 +162,39 @@ export function genRoomVia(room) {
   }
   return via.concat(mostPop3.slice(0, 2));
 }
+
+export function isCrossVerified(deviceId) {
+  try {
+    const mx = initMatrix.matrixClient;
+    const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());
+    const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
+    const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
+    return deviceTrust.isCrossSigningVerified();
+  } catch {
+    return false;
+  }
+}
+
+export function hasCrossSigningAccountData() {
+  const mx = initMatrix.matrixClient;
+  const masterKeyData = mx.getAccountData('m.cross_signing.master');
+  return !!masterKeyData;
+}
+
+export function getDefaultSSKey() {
+  const mx = initMatrix.matrixClient;
+  try {
+    return mx.getAccountData('m.secret_storage.default_key').getContent().key;
+  } catch {
+    return undefined;
+  }
+}
+
+export function getSSKeyInfo(key) {
+  const mx = initMatrix.matrixClient;
+  try {
+    return mx.getAccountData(`m.secret_storage.key.${key}`).getContent();
+  } catch {
+    return undefined;
+  }
+}
index 9ff58daf588d4abc78ac358e3e2b1d78fff7e894..db5eedd9fe1acd8b76ff23f93bc5a335c9371e6e 100644 (file)
@@ -1,6 +1,7 @@
 const HtmlWebpackPlugin = require('html-webpack-plugin');
 const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
 const CopyPlugin = require("copy-webpack-plugin");
+const webpack = require('webpack');
 
 module.exports = {
   entry: {
@@ -17,6 +18,7 @@ module.exports = {
       'util': require.resolve('util/'),
       'assert': require.resolve('assert/'),
       'url': require.resolve('url/'),
+      'buffer': require.resolve('buffer'),
     }
   },
   node: {
@@ -73,5 +75,8 @@ module.exports = {
         { from: 'config.json' },
       ],
     }),
+    new webpack.ProvidePlugin({
+      Buffer: ['buffer', 'Buffer'],
+    }),
   ],
 };