Add support to manage cross-signing and key backup (#461)
* 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
This commit is contained in:
parent
ec26c03d58
commit
989ab5a432
26 changed files with 1261 additions and 87 deletions
59
src/app/atoms/card/InfoCard.jsx
Normal file
59
src/app/atoms/card/InfoCard.jsx
Normal file
|
@ -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;
|
79
src/app/atoms/card/InfoCard.scss
Normal file
79
src/app/atoms/card/InfoCard.scss
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
src/app/hooks/useCrossSigningStatus.js
Normal file
25
src/app/hooks/useCrossSigningStatus.js
Normal file
|
@ -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;
|
||||||
|
}
|
32
src/app/hooks/useDeviceList.js
Normal file
32
src/app/hooks/useDeviceList.js
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -37,7 +37,7 @@ function Dialog({
|
||||||
{contentOptions}
|
{contentOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="dialog__content__wrapper">
|
<div className="dialog__content__wrapper">
|
||||||
<ScrollView autoHide invisible={invisibleScroll}>
|
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||||
<div className="dialog__content-container">
|
<div className="dialog__content-container">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,7 +24,7 @@ function ReusableDialog() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAfterClose = () => {
|
const handleAfterClose = () => {
|
||||||
data.afterClose();
|
data.afterClose?.();
|
||||||
setData(null);
|
setData(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
|
||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
import { blurOnBubbling } from '../../atoms/button/script';
|
||||||
|
|
||||||
const SidebarAvatar = React.forwardRef(({
|
const SidebarAvatar = React.forwardRef(({
|
||||||
tooltip, active, onClick, onContextMenu,
|
className, tooltip, active, onClick,
|
||||||
avatar, notificationBadge,
|
onContextMenu, avatar, notificationBadge,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
let activeClass = '';
|
const classes = ['sidebar-avatar'];
|
||||||
if (active) activeClass = ' sidebar-avatar--active';
|
if (active) classes.push('sidebar-avatar--active');
|
||||||
|
if (className) classes.push(className);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
||||||
|
@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`sidebar-avatar${activeClass}`}
|
className={classes.join(' ')}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
@ -34,6 +35,7 @@ const SidebarAvatar = React.forwardRef(({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
SidebarAvatar.defaultProps = {
|
SidebarAvatar.defaultProps = {
|
||||||
|
className: null,
|
||||||
active: false,
|
active: false,
|
||||||
onClick: null,
|
onClick: null,
|
||||||
onContextMenu: null,
|
onContextMenu: null,
|
||||||
|
@ -41,6 +43,7 @@ SidebarAvatar.defaultProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
SidebarAvatar.propTypes = {
|
SidebarAvatar.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
|
|
@ -91,6 +91,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-group {
|
.emoji-group {
|
||||||
--emoji-padding: 6px;
|
--emoji-padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from '../../../client/action/navigation';
|
} from '../../../client/action/navigation';
|
||||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||||
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
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 AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import InviteIC from '../../../../public/res/ic/outlined/invite.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 { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||||
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||||
|
|
||||||
|
import { tabText as settingTabText } from '../settings/Settings';
|
||||||
|
|
||||||
function useNotificationUpdate() {
|
function useNotificationUpdate() {
|
||||||
const { notifications } = initMatrix;
|
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() {
|
function FeaturedTab() {
|
||||||
const { roomList, accountData, notifications } = initMatrix;
|
const { roomList, accountData, notifications } = initMatrix;
|
||||||
const [selectedTab] = useSelectedTab();
|
const [selectedTab] = useSelectedTab();
|
||||||
|
@ -358,6 +379,7 @@ function SideBar() {
|
||||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CrossSigninAlert />
|
||||||
<ProfileAvatarMenu />
|
<ProfileAvatarMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -57,4 +57,21 @@
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: var(--bg-surface-border);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
|
@ -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;
|
12
src/app/organisms/settings/AuthRequest.scss
Normal file
12
src/app/organisms/settings/AuthRequest.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
223
src/app/organisms/settings/CrossSigning.jsx
Normal file
223
src/app/organisms/settings/CrossSigning.jsx
Normal file
|
@ -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;
|
55
src/app/organisms/settings/CrossSigning.scss
Normal file
55
src/app/organisms/settings/CrossSigning.scss
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,62 +3,30 @@ import './DeviceManage.scss';
|
||||||
import dateFormat from 'dateformat';
|
import dateFormat from 'dateformat';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import InfoCard from '../../atoms/card/InfoCard';
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||||
|
|
||||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||||
|
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||||
|
|
||||||
|
import { authRequest } from './AuthRequest';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||||
function useDeviceList() {
|
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceManage() {
|
function DeviceManage() {
|
||||||
const TRUNCATED_COUNT = 4;
|
const TRUNCATED_COUNT = 4;
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const isCSEnabled = useCrossSigningStatus();
|
||||||
const deviceList = useDeviceList();
|
const deviceList = useDeviceList();
|
||||||
const [processing, setProcessing] = useState([]);
|
const [processing, setProcessing] = useState([]);
|
||||||
const [truncated, setTruncated] = useState(true);
|
const [truncated, setTruncated] = useState(true);
|
||||||
|
@ -105,38 +73,15 @@ function DeviceManage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async (device, auth = undefined) => {
|
const handleRemove = async (device) => {
|
||||||
if (auth === undefined
|
if (window.confirm(`You are about to logout "${device.display_name}" session.`)) {
|
||||||
? window.confirm(`You are about to logout "${device.display_name}" session.`)
|
|
||||||
: true
|
|
||||||
) {
|
|
||||||
addToProcessing(device);
|
addToProcessing(device);
|
||||||
try {
|
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
|
||||||
await mx.deleteDevice(device.device_id, auth);
|
await mx.deleteDevice(device.device_id, auth);
|
||||||
} catch (e) {
|
});
|
||||||
if (e.httpStatus === 401 && e.data?.flows) {
|
|
||||||
const { flows } = e.data;
|
if (!mountStore.getItem()) return;
|
||||||
const flow = flows.find((f) => f.stages.includes('m.login.password'));
|
removeFromProcessing(device);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -187,6 +132,16 @@ function DeviceManage() {
|
||||||
<div className="device-manage">
|
<div className="device-manage">
|
||||||
<div>
|
<div>
|
||||||
<MenuHeader>Unverified sessions</MenuHeader>
|
<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.length > 0
|
||||||
? unverified.map((device) => renderDevice(device, false))
|
? unverified.map((device) => renderDevice(device, false))
|
||||||
|
|
288
src/app/organisms/settings/KeyBackup.jsx
Normal file
288
src/app/organisms/settings/KeyBackup.jsx
Normal file
|
@ -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;
|
27
src/app/organisms/settings/KeyBackup.scss
Normal file
27
src/app/organisms/settings/KeyBackup.scss
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
133
src/app/organisms/settings/SecretStorageAccess.jsx
Normal file
133
src/app/organisms/settings/SecretStorageAccess.jsx
Normal file
|
@ -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;
|
20
src/app/organisms/settings/SecretStorageAccess.scss
Normal file
20
src/app/organisms/settings/SecretStorageAccess.scss
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
||||||
|
|
||||||
import ProfileEditor from '../profile-editor/ProfileEditor';
|
import ProfileEditor from '../profile-editor/ProfileEditor';
|
||||||
|
import CrossSigning from './CrossSigning';
|
||||||
|
import KeyBackup from './KeyBackup';
|
||||||
import DeviceManage from './DeviceManage';
|
import DeviceManage from './DeviceManage';
|
||||||
|
|
||||||
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
||||||
|
@ -168,18 +170,13 @@ function SecuritySection() {
|
||||||
return (
|
return (
|
||||||
<div className="settings-security">
|
<div className="settings-security">
|
||||||
<div className="settings-security__card">
|
<div className="settings-security__card">
|
||||||
<MenuHeader>Session Info</MenuHeader>
|
<MenuHeader>Cross signing and backup</MenuHeader>
|
||||||
<SettingTile
|
<CrossSigning />
|
||||||
title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
|
<KeyBackup />
|
||||||
/>
|
|
||||||
<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>}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DeviceManage />
|
<DeviceManage />
|
||||||
<div className="settings-security__card">
|
<div className="settings-security__card">
|
||||||
<MenuHeader>Encryption</MenuHeader>
|
<MenuHeader>Export/Import encryption keys</MenuHeader>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Export E2E room keys"
|
title="Export E2E room keys"
|
||||||
content={(
|
content={(
|
||||||
|
@ -247,7 +244,7 @@ function AboutSection() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabText = {
|
export const tabText = {
|
||||||
APPEARANCE: 'Appearance',
|
APPEARANCE: 'Appearance',
|
||||||
NOTIFICATIONS: 'Notifications',
|
NOTIFICATIONS: 'Notifications',
|
||||||
SECURITY: 'Security',
|
SECURITY: 'Security',
|
||||||
|
|
|
@ -7,6 +7,7 @@ import RoomList from './state/RoomList';
|
||||||
import AccountData from './state/AccountData';
|
import AccountData from './state/AccountData';
|
||||||
import RoomsInput from './state/RoomsInput';
|
import RoomsInput from './state/RoomsInput';
|
||||||
import Notifications from './state/Notifications';
|
import Notifications from './state/Notifications';
|
||||||
|
import { cryptoCallbacks } from './state/secretStorageKeys';
|
||||||
|
|
||||||
global.Olm = require('@matrix-org/olm');
|
global.Olm = require('@matrix-org/olm');
|
||||||
|
|
||||||
|
@ -36,6 +37,7 @@ class InitMatrix extends EventEmitter {
|
||||||
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
|
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
|
||||||
deviceId: secret.deviceId,
|
deviceId: secret.deviceId,
|
||||||
timelineSupport: true,
|
timelineSupport: true,
|
||||||
|
cryptoCallbacks,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.matrixClient.initCrypto();
|
await this.matrixClient.initCrypto();
|
||||||
|
|
41
src/client/state/secretStorageKeys.js
Normal file
41
src/client/state/secretStorageKeys.js
Normal file
|
@ -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,
|
||||||
|
};
|
|
@ -69,9 +69,13 @@
|
||||||
--ic-surface-high: #272727;
|
--ic-surface-high: #272727;
|
||||||
--ic-surface-normal: #626262;
|
--ic-surface-normal: #626262;
|
||||||
--ic-surface-low: #7c7c7c;
|
--ic-surface-low: #7c7c7c;
|
||||||
|
--ic-primary-high: #ffffff;
|
||||||
--ic-primary-normal: #ffffff;
|
--ic-primary-normal: #ffffff;
|
||||||
|
--ic-positive-high: rgba(69, 184, 59);
|
||||||
--ic-positive-normal: rgba(69, 184, 59, 80%);
|
--ic-positive-normal: rgba(69, 184, 59, 80%);
|
||||||
|
--ic-caution-high: rgba(255, 179, 0);
|
||||||
--ic-caution-normal: rgba(255, 179, 0, 80%);
|
--ic-caution-normal: rgba(255, 179, 0, 80%);
|
||||||
|
--ic-danger-high: rgba(240, 71, 71);
|
||||||
--ic-danger-normal: rgba(240, 71, 71, 0.7);
|
--ic-danger-normal: rgba(240, 71, 71, 0.7);
|
||||||
|
|
||||||
/* user mxid colors */
|
/* user mxid colors */
|
||||||
|
|
|
@ -114,3 +114,21 @@ export function getScrollInfo(target) {
|
||||||
export function avatarInitials(text) {
|
export function avatarInitials(text) {
|
||||||
return [...text][0];
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -162,3 +162,39 @@ export function genRoomVia(room) {
|
||||||
}
|
}
|
||||||
return via.concat(mostPop3.slice(0, 2));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
||||||
const CopyPlugin = require("copy-webpack-plugin");
|
const CopyPlugin = require("copy-webpack-plugin");
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
|
@ -17,6 +18,7 @@ module.exports = {
|
||||||
'util': require.resolve('util/'),
|
'util': require.resolve('util/'),
|
||||||
'assert': require.resolve('assert/'),
|
'assert': require.resolve('assert/'),
|
||||||
'url': require.resolve('url/'),
|
'url': require.resolve('url/'),
|
||||||
|
'buffer': require.resolve('buffer'),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
|
@ -73,5 +75,8 @@ module.exports = {
|
||||||
{ from: 'config.json' },
|
{ from: 'config.json' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
new webpack.ProvidePlugin({
|
||||||
|
Buffer: ['buffer', 'Buffer'],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue