--- /dev/null
+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;
--- /dev/null
+@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
--- /dev/null
+/* 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;
+}
--- /dev/null
+/* 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;
+}
{contentOptions}
</Header>
<div className="dialog__content__wrapper">
- <ScrollView autoHide invisible={invisibleScroll}>
+ <ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
<div className="dialog__content-container">
{children}
</div>
}, []);
const handleAfterClose = () => {
- data.afterClose();
+ data.afterClose?.();
setData(null);
};
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>}
>
<button
ref={ref}
- className={`sidebar-avatar${activeClass}`}
+ className={classes.join(' ')}
type="button"
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
onClick={onClick}
);
});
SidebarAvatar.defaultProps = {
+ className: null,
active: false,
onClick: null,
onContextMenu: null,
};
SidebarAvatar.propTypes = {
+ className: PropTypes.string,
tooltip: PropTypes.string.isRequired,
active: PropTypes.bool,
onClick: PropTypes.func,
}
}
+.emoji-row {
+ display: flex;
+}
+
.emoji-group {
--emoji-padding: 6px;
position: relative;
} 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';
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;
);
}
+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();
notificationBadge={<NotificationBadge alert content={totalInvites} />}
/>
)}
+ <CrossSigninAlert />
<ProfileAvatarMenu />
</div>
</div>
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
--- /dev/null
+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;
--- /dev/null
+.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
--- /dev/null
+/* 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;
--- /dev/null
+.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
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);
}
};
- 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);
}
};
<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))
--- /dev/null
+/* 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;
--- /dev/null
+.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
--- /dev/null
+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;
--- /dev/null
+.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
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';
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={(
);
}
-const tabText = {
+export const tabText = {
APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications',
SECURITY: 'Security',
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');
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
deviceId: secret.deviceId,
timelineSupport: true,
+ cryptoCallbacks,
});
await this.matrixClient.initCrypto();
--- /dev/null
+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,
+};
--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 */
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();
+ }
+}
}
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;
+ }
+}
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: {
'util': require.resolve('util/'),
'assert': require.resolve('assert/'),
'url': require.resolve('url/'),
+ 'buffer': require.resolve('buffer'),
}
},
node: {
{ from: 'config.json' },
],
}),
+ new webpack.ProvidePlugin({
+ Buffer: ['buffer', 'Buffer'],
+ }),
],
};