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:
Ajay Bura 2022-04-24 15:42:24 +05:30 committed by GitHub
parent ec26c03d58
commit 989ab5a432
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1261 additions and 87 deletions

View file

@ -91,6 +91,10 @@
}
}
.emoji-row {
display: flex;
}
.emoji-group {
--emoji-padding: 6px;
position: relative;

View file

@ -14,6 +14,7 @@ import {
} from '../../../client/action/navigation';
import { moveSpaceShortcut } from '../../../client/action/accountData';
import { abbreviateNumber, getEventCords } from '../../../util/common';
import { isCrossVerified } from '../../../util/matrixUtil';
import Avatar from '../../atoms/avatar/Avatar';
import NotificationBadge from '../../atoms/badge/NotificationBadge';
@ -26,8 +27,12 @@ import UserIC from '../../../../public/res/ic/outlined/user.svg';
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import { useSelectedTab } from '../../hooks/useSelectedTab';
import { useDeviceList } from '../../hooks/useDeviceList';
import { tabText as settingTabText } from '../settings/Settings';
function useNotificationUpdate() {
const { notifications } = initMatrix;
@ -85,6 +90,22 @@ function ProfileAvatarMenu() {
);
}
function CrossSigninAlert() {
const deviceList = useDeviceList();
const unverified = deviceList?.filter((device) => !isCrossVerified(device.device_id));
if (!unverified?.length) return null;
return (
<SidebarAvatar
className="sidebar__cross-signin-alert"
tooltip={`${unverified.length} unverified sessions`}
onClick={() => openSettings(settingTabText.SECURITY)}
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
/>
);
}
function FeaturedTab() {
const { roomList, accountData, notifications } = initMatrix;
const [selectedTab] = useSelectedTab();
@ -358,6 +379,7 @@ function SideBar() {
notificationBadge={<NotificationBadge alert content={totalInvites} />}
/>
)}
<CrossSigninAlert />
<ProfileAvatarMenu />
</div>
</div>

View file

@ -57,4 +57,21 @@
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);
}
}

View 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;

View 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;
}
}

View 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;

View 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);
}
}

View file

@ -3,62 +3,30 @@ import './DeviceManage.scss';
import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix';
import { isCrossVerified } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import InfoCard from '../../atoms/card/InfoCard';
import Spinner from '../../atoms/spinner/Spinner';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import { authRequest } from './AuthRequest';
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;
}
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 { useDeviceList } from '../../hooks/useDeviceList';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
function DeviceManage() {
const TRUNCATED_COUNT = 4;
const mx = initMatrix.matrixClient;
const isCSEnabled = useCrossSigningStatus();
const deviceList = useDeviceList();
const [processing, setProcessing] = useState([]);
const [truncated, setTruncated] = useState(true);
@ -105,38 +73,15 @@ function DeviceManage() {
}
};
const handleRemove = async (device, auth = undefined) => {
if (auth === undefined
? window.confirm(`You are about to logout "${device.display_name}" session.`)
: true
) {
const handleRemove = async (device) => {
if (window.confirm(`You are about to logout "${device.display_name}" session.`)) {
addToProcessing(device);
try {
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
await mx.deleteDevice(device.device_id, auth);
} catch (e) {
if (e.httpStatus === 401 && e.data?.flows) {
const { flows } = e.data;
const flow = flows.find((f) => f.stages.includes('m.login.password'));
if (flow) {
const password = window.prompt('Please enter account password', '');
if (password && password.trim() !== '') {
handleRemove(device, {
session: e.data.session,
type: 'm.login.password',
password,
identifier: {
type: 'm.id.user',
user: mx.getUserId(),
},
});
return;
}
}
}
window.alert('Failed to remove session!');
if (!mountStore.getItem()) return;
removeFromProcessing(device);
}
});
if (!mountStore.getItem()) return;
removeFromProcessing(device);
}
};
@ -187,6 +132,16 @@ function DeviceManage() {
<div className="device-manage">
<div>
<MenuHeader>Unverified sessions</MenuHeader>
{!isCSEnabled && (
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
<InfoCard
rounded
variant="caution"
iconSrc={InfoIC}
title="Setup cross signing in case you lose all your sessions."
/>
</div>
)}
{
unverified.length > 0
? unverified.map((device) => renderDevice(device, false))

View 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;

View 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);
}
}

View 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;

View 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);
}
}

View file

@ -26,6 +26,8 @@ import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/Impor
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
import ProfileEditor from '../profile-editor/ProfileEditor';
import CrossSigning from './CrossSigning';
import KeyBackup from './KeyBackup';
import DeviceManage from './DeviceManage';
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
@ -168,18 +170,13 @@ function SecuritySection() {
return (
<div className="settings-security">
<div className="settings-security__card">
<MenuHeader>Session Info</MenuHeader>
<SettingTile
title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
/>
<SettingTile
title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
content={<Text variant="b3">Use this session ID-key combo to verify or manage this session.</Text>}
/>
<MenuHeader>Cross signing and backup</MenuHeader>
<CrossSigning />
<KeyBackup />
</div>
<DeviceManage />
<div className="settings-security__card">
<MenuHeader>Encryption</MenuHeader>
<MenuHeader>Export/Import encryption keys</MenuHeader>
<SettingTile
title="Export E2E room keys"
content={(
@ -247,7 +244,7 @@ function AboutSection() {
);
}
const tabText = {
export const tabText = {
APPEARANCE: 'Appearance',
NOTIFICATIONS: 'Notifications',
SECURITY: 'Security',