Add SSSS and key backup
This commit is contained in:
parent
89f69ed5b4
commit
101134e313
7 changed files with 188 additions and 62 deletions
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;
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
import React, { useState } from 'react';
|
||||
import './CrossSigning.scss';
|
||||
import FileSaver from 'file-saver';
|
||||
|
@ -6,7 +7,6 @@ import { twemojify } from '../../../util/twemojify';
|
|||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { hasCrossSigningAccountData } from '../../../util/matrixUtil';
|
||||
import { copyToClipboard } from '../../../util/common';
|
||||
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
|
@ -16,6 +16,24 @@ 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], {
|
||||
|
@ -66,28 +84,19 @@ function CrossSigningSetup() {
|
|||
});
|
||||
|
||||
const authUploadDeviceSigningKeys = async (makeRequest) => {
|
||||
try {
|
||||
const password = prompt('Password', '');
|
||||
await makeRequest({
|
||||
type: 'm.login.password',
|
||||
password,
|
||||
identifier: {
|
||||
type: 'm.id.user',
|
||||
user: mx.getUserId(),
|
||||
},
|
||||
const isDone = await authRequest('Setup cross signing', async (auth) => {
|
||||
await makeRequest(auth);
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (isDone) securityKeyDialog(recoveryKey);
|
||||
else failedDialog();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// TODO: handle error
|
||||
}
|
||||
};
|
||||
|
||||
await mx.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
|
||||
securityKeyDialog(recoveryKey);
|
||||
};
|
||||
|
||||
const validator = (values) => {
|
||||
|
@ -112,7 +121,7 @@ function CrossSigningSetup() {
|
|||
<div className="cross-signing__setup">
|
||||
<div className="cross-signing__setup-entry">
|
||||
<Text>
|
||||
We will generate a Security Key,
|
||||
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>}
|
||||
|
@ -133,7 +142,7 @@ function CrossSigningSetup() {
|
|||
disabled={genWithPhrase !== undefined}
|
||||
>
|
||||
<Text>
|
||||
Alternatively you can also set a Security Phrase
|
||||
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>
|
||||
|
@ -181,7 +190,8 @@ function CrossSigningReset() {
|
|||
<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 Security Key or Phrase and every session you can cross-sign from.
|
||||
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>
|
||||
|
@ -196,12 +206,13 @@ const resetDialog = () => {
|
|||
};
|
||||
|
||||
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={(
|
||||
hasCrossSigningAccountData()
|
||||
isCSEnabled
|
||||
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
||||
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
||||
)}
|
||||
|
|
|
@ -45,10 +45,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.cross-signing__failure,
|
||||
.cross-signing__reset {
|
||||
padding: var(--sp-normal);
|
||||
padding-top: var(--sp-extra-loose);
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-loose);
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ 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';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
|
@ -71,39 +73,16 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderDevice = (device, isVerified) => {
|
||||
|
|
|
@ -82,7 +82,7 @@ CreateKeyBackupDialog.propTypes = {
|
|||
keyData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
||||
function RestoreKeyBackupDialog({ keyData }) {
|
||||
const [status, setStatus] = useState(false);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
@ -103,6 +103,7 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
|||
};
|
||||
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
const info = await mx.restoreKeyBackupWithSecretStorage(
|
||||
backupInfo,
|
||||
undefined,
|
||||
|
@ -115,7 +116,7 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
|||
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' });
|
||||
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
|
||||
} else {
|
||||
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
|
||||
}
|
||||
|
@ -152,10 +153,9 @@ function RestoreKeyBackupDialog({ keyData, backupInfo }) {
|
|||
}
|
||||
RestoreKeyBackupDialog.propTypes = {
|
||||
keyData: PropTypes.shape({}).isRequired,
|
||||
backupInfo: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function DeleteKeyBackupDialog({ version, requestClose }) {
|
||||
function DeleteKeyBackupDialog({ requestClose }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
@ -164,7 +164,8 @@ function DeleteKeyBackupDialog({ version, requestClose }) {
|
|||
const deleteBackup = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await mx.deleteKeyBackupVersion(version);
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
|
||||
if (!mountStore.getItem()) return;
|
||||
requestClose(true);
|
||||
} catch {
|
||||
|
@ -187,7 +188,6 @@ function DeleteKeyBackupDialog({ version, requestClose }) {
|
|||
);
|
||||
}
|
||||
DeleteKeyBackupDialog.propTypes = {
|
||||
version: PropTypes.string.isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
@ -217,7 +217,7 @@ function KeyBackup() {
|
|||
return () => {
|
||||
mx.removeListener('accountData', handleAccountData);
|
||||
};
|
||||
}, []);
|
||||
}, [isCSEnabled]);
|
||||
|
||||
const openCreateKeyBackup = async () => {
|
||||
const keyData = await accessSecretStorage('Create Key Backup');
|
||||
|
@ -236,7 +236,7 @@ function KeyBackup() {
|
|||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} backupInfo={keyBackup} />,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} />,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -244,7 +244,6 @@ function KeyBackup() {
|
|||
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
||||
(requestClose) => (
|
||||
<DeleteKeyBackupDialog
|
||||
version={keyBackup.version}
|
||||
requestClose={(isDone) => {
|
||||
if (isDone) setKeyBackup(null);
|
||||
requestClose();
|
||||
|
|
|
@ -116,7 +116,14 @@ export const accessSecretStorage = (title) => new Promise((resolve) => {
|
|||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
() => <SecretStorageAccess onComplete={handleComplete} />,
|
||||
(requestClose) => (
|
||||
<SecretStorageAccess
|
||||
onComplete={(keyData) => {
|
||||
handleComplete(keyData);
|
||||
requestClose(requestClose);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(null);
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue