diff --git a/src/app/atoms/card/InfoCard.jsx b/src/app/atoms/card/InfoCard.jsx new file mode 100644 index 00000000..e530d5c0 --- /dev/null +++ b/src/app/atoms/card/InfoCard.jsx @@ -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 ( +
+ {iconSrc && ( +
+ +
+ )} +
+ {title} + {content} +
+ {requestClose && ( + + )} +
+ ); +} + +InfoCard.defaultProps = { + className: null, + style: null, + variant: 'surface', + iconSrc: null, + content: null, + rounded: false, + requestClose: null, +}; + +InfoCard.propTypes = { + className: PropTypes.string, + style: PropTypes.shape({}), + variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']), + iconSrc: PropTypes.string, + title: PropTypes.string.isRequired, + content: PropTypes.node, + rounded: PropTypes.bool, + requestClose: PropTypes.func, +}; + +export default InfoCard; diff --git a/src/app/atoms/card/InfoCard.scss b/src/app/atoms/card/InfoCard.scss new file mode 100644 index 00000000..79d72ebb --- /dev/null +++ b/src/app/atoms/card/InfoCard.scss @@ -0,0 +1,79 @@ +@use '.././../partials/flex'; +@use '.././../partials/dir'; + +.info-card { + display: flex; + align-items: flex-start; + line-height: 0; + padding: var(--sp-tight); + @include dir.prop(border-left, 4px solid transparent, none); + @include dir.prop(border-right, none, 4px solid transparent); + + & > .ic-btn { + padding: 0; + border-radius: 4; + } + + &__content { + margin: 0 var(--sp-tight); + @extend .cp-fx__item-one; + + & > *:nth-child(2) { + margin-top: var(--sp-ultra-tight); + } + } + + &--rounded { + @include dir.prop( + border-radius, + 0 var(--bo-radius) var(--bo-radius) 0, + var(--bo-radius) 0 0 var(--bo-radius) + ); + } + + &--surface { + border-color: var(--bg-surface-border); + background-color: var(--bg-surface-hover); + + } + &--primary { + border-color: var(--bg-primary); + background-color: var(--bg-primary-hover); + & .text { + color: var(--tc-primary-high); + &-b3 { + color: var(--tc-primary-normal); + } + } + } + &--positive { + border-color: var(--bg-positive-border); + background-color: var(--bg-positive-hover); + & .text { + color: var(--tc-positive-high); + &-b3 { + color: var(--tc-positive-normal); + } + } + } + &--caution { + border-color: var(--bg-caution-border); + background-color: var(--bg-caution-hover); + & .text { + color: var(--tc-caution-high); + &-b3 { + color: var(--tc-caution-normal); + } + } + } + &--danger { + border-color: var(--bg-danger-border); + background-color: var(--bg-danger-hover); + & .text { + color: var(--tc-danger-high); + &-b3 { + color: var(--tc-danger-normal); + } + } + } +} \ No newline at end of file diff --git a/src/app/hooks/useCrossSigningStatus.js b/src/app/hooks/useCrossSigningStatus.js new file mode 100644 index 00000000..61b69d1d --- /dev/null +++ b/src/app/hooks/useCrossSigningStatus.js @@ -0,0 +1,25 @@ +/* eslint-disable import/prefer-default-export */ +import { useState, useEffect } from 'react'; + +import initMatrix from '../../client/initMatrix'; +import { hasCrossSigningAccountData } from '../../util/matrixUtil'; + +export function useCrossSigningStatus() { + const mx = initMatrix.matrixClient; + const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData()); + + useEffect(() => { + if (isCSEnabled) return null; + const handleAccountData = (event) => { + if (event.getType() === 'm.cross_signing.master') { + setIsCSEnabled(true); + } + }; + + mx.on('accountData', handleAccountData); + return () => { + mx.removeListener('accountData', handleAccountData); + }; + }, [isCSEnabled === false]); + return isCSEnabled; +} diff --git a/src/app/hooks/useDeviceList.js b/src/app/hooks/useDeviceList.js new file mode 100644 index 00000000..2cce0fe5 --- /dev/null +++ b/src/app/hooks/useDeviceList.js @@ -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; +} diff --git a/src/app/molecules/dialog/Dialog.jsx b/src/app/molecules/dialog/Dialog.jsx index 637766a9..a59520f6 100644 --- a/src/app/molecules/dialog/Dialog.jsx +++ b/src/app/molecules/dialog/Dialog.jsx @@ -37,7 +37,7 @@ function Dialog({ {contentOptions}
- +
{children}
diff --git a/src/app/molecules/dialog/ReusableDialog.jsx b/src/app/molecules/dialog/ReusableDialog.jsx index b05cafcf..7340e119 100644 --- a/src/app/molecules/dialog/ReusableDialog.jsx +++ b/src/app/molecules/dialog/ReusableDialog.jsx @@ -24,7 +24,7 @@ function ReusableDialog() { }, []); const handleAfterClose = () => { - data.afterClose(); + data.afterClose?.(); setData(null); }; diff --git a/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx b/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx index 7b281454..bc8b7c8f 100644 --- a/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx +++ b/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx @@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip'; import { blurOnBubbling } from '../../atoms/button/script'; const SidebarAvatar = React.forwardRef(({ - tooltip, active, onClick, onContextMenu, - avatar, notificationBadge, + className, tooltip, active, onClick, + onContextMenu, avatar, notificationBadge, }, ref) => { - let activeClass = ''; - if (active) activeClass = ' sidebar-avatar--active'; + const classes = ['sidebar-avatar']; + if (active) classes.push('sidebar-avatar--active'); + if (className) classes.push(className); return ( {twemojify(tooltip)}} @@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({ >
diff --git a/src/app/organisms/navigation/SideBar.scss b/src/app/organisms/navigation/SideBar.scss index 9f9ade72..6ff66288 100644 --- a/src/app/organisms/navigation/SideBar.scss +++ b/src/app/organisms/navigation/SideBar.scss @@ -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); + } } \ No newline at end of file diff --git a/src/app/organisms/settings/AuthRequest.jsx b/src/app/organisms/settings/AuthRequest.jsx new file mode 100644 index 00000000..ca07c2a2 --- /dev/null +++ b/src/app/organisms/settings/AuthRequest.jsx @@ -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 ( +
+
+ + {status.ongoing && } + {status.error && {status.error}} + {(status === false || status.error) && } + +
+ ); +} +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} 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( + {title}, + (requestClose) => ( + { + isCompleted = true; + resolve(done); + requestClose(); + }} + makeRequest={makeRequest} + /> + ), + () => { + if (!isCompleted) resolve(false); + }, + ); + }); + } +}; + +export default AuthRequest; diff --git a/src/app/organisms/settings/AuthRequest.scss b/src/app/organisms/settings/AuthRequest.scss new file mode 100644 index 00000000..35e95bf2 --- /dev/null +++ b/src/app/organisms/settings/AuthRequest.scss @@ -0,0 +1,12 @@ +.auth-request { + padding: var(--sp-normal); + + & form > *:not(:first-child) { + margin-top: var(--sp-normal); + } + + & .text-b3 { + color: var(--tc-danger-high); + margin-top: var(--sp-ultra-tight) !important; + } +} \ No newline at end of file diff --git a/src/app/organisms/settings/CrossSigning.jsx b/src/app/organisms/settings/CrossSigning.jsx new file mode 100644 index 00000000..9213e9da --- /dev/null +++ b/src/app/organisms/settings/CrossSigning.jsx @@ -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) => ( +
+ {twemojify('❌')} + Failed to setup cross signing. Please try again. + +
+ ); + + openReusableDialog( + Setup cross signing, + 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 = () => ( +
+ Please save this security key somewhere safe. + + {key.encodedPrivateKey} + +
+ + +
+
+ ); + + // Download automatically. + downloadKey(); + + openReusableDialog( + Security Key, + () => 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 ( +
+
+ + We will generate a Security Key, + which you can use to manage messages backup and session verification. + + {genWithPhrase !== false && } + {genWithPhrase === false && } +
+ OR + setup(values.phrase)} + validate={validator} + > + {({ + values, errors, handleChange, handleSubmit, + }) => ( +
+ + Alternatively you can also set a Security Phrase + so you don't have to remember long Security Key, + and optionally save the Key as backup. + + + {errors.phrase && {errors.phrase}} + + {errors.confirmPhrase && {errors.confirmPhrase}} + {genWithPhrase !== true && } + {genWithPhrase === true && } + + )} +
+
+ ); +} + +const setupDialog = () => { + openReusableDialog( + Setup cross signing, + () => , + ); +}; + +function CrossSigningReset() { + return ( +
+ {twemojify('✋🧑‍🚒🤚')} + Resetting cross-signing keys is permanent. + + 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. + + +
+ ); +} + +const resetDialog = () => { + openReusableDialog( + Reset cross signing, + () => , + ); +}; + +function CrossSignin() { + const isCSEnabled = useCrossSigningStatus(); + return ( + Setup to verify and keep track of all your sessions. Also required to backup encrypted message.} + options={( + isCSEnabled + ? + : + )} + /> + ); +} + +export default CrossSignin; diff --git a/src/app/organisms/settings/CrossSigning.scss b/src/app/organisms/settings/CrossSigning.scss new file mode 100644 index 00000000..b4b606d0 --- /dev/null +++ b/src/app/organisms/settings/CrossSigning.scss @@ -0,0 +1,55 @@ +.cross-signing { + &__setup { + padding: var(--sp-normal); + } + &__setup-entry { + & > *:not(:first-child) { + margin-top: var(--sp-normal); + } + } + + &__error { + color: var(--tc-danger-high); + margin-top: var(--sp-ultra-tight) !important; + } + + &__setup-divider { + margin: var(--sp-tight) 0; + display: flex; + align-items: center; + + &::before, + &::after { + flex: 1; + content: ''; + margin: var(--sp-tight) 0; + border-bottom: 1px solid var(--bg-surface-border); + } + } +} + +.cross-signing__key { + padding: var(--sp-normal); + + &-text { + margin: var(--sp-normal) 0; + padding: var(--sp-extra-tight); + background-color: var(--bg-surface-low); + border-radius: var(--bo-radius); + } + &-btn { + display: flex; + & > button:last-child { + margin: 0 var(--sp-normal); + } + } +} + +.cross-signing__failure, +.cross-signing__reset { + padding: var(--sp-normal); + padding-top: var(--sp-extra-loose); + & > .text { + padding-bottom: var(--sp-normal); + } +} \ No newline at end of file diff --git a/src/app/organisms/settings/DeviceManage.jsx b/src/app/organisms/settings/DeviceManage.jsx index 6beb2dc6..5c60bf0a 100644 --- a/src/app/organisms/settings/DeviceManage.jsx +++ b/src/app/organisms/settings/DeviceManage.jsx @@ -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() {
Unverified sessions + {!isCSEnabled && ( +
+ +
+ )} { unverified.length > 0 ? unverified.map((device) => renderDevice(device, false)) diff --git a/src/app/organisms/settings/KeyBackup.jsx b/src/app/organisms/settings/KeyBackup.jsx new file mode 100644 index 00000000..5d2f4ed7 --- /dev/null +++ b/src/app/organisms/settings/KeyBackup.jsx @@ -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 ( +
+ {done === false && ( +
+ + Creating backup... +
+ )} + {done === true && ( + <> + {twemojify('✅')} + Successfully created backup + + )} + {done === null && ( + <> + Failed to create backup + + + )} +
+ ); +} +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 ( +
+ {(status === false || status.message) && ( +
+ + {status.message ?? 'Restoring backup keys...'} +
+ )} + {status.done && ( + <> + {twemojify('✅')} + {status.done} + + )} + {status.error && ( + <> + {status.error} + + + )} +
+ ); +} +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 ( +
+ {twemojify('🗑')} + Deleting key backup is permanent. + All encrypted messages keys stored on server will be deleted. + { + isDeleting + ? + : + } +
+ ); +} +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( + Create Key Backup, + () => , + () => fetchKeyBackupVersion(), + ); + }; + + const openRestoreKeyBackup = async () => { + const keyData = await accessSecretStorage('Restore Key Backup'); + if (keyData === null) return; + + openReusableDialog( + Restore Key Backup, + () => , + ); + }; + + const openDeleteKeyBackup = () => openReusableDialog( + Delete Key Backup, + (requestClose) => ( + { + if (isDone) setKeyBackup(null); + requestClose(); + }} + /> + ), + ); + + const renderOptions = () => { + if (keyBackup === undefined) return ; + if (keyBackup === null) return ; + return ( + <> + + + + ); + }; + + return ( + + 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. + {!isCSEnabled && ( + + )} + + )} + options={isCSEnabled ? renderOptions() : null} + /> + ); +} + +export default KeyBackup; diff --git a/src/app/organisms/settings/KeyBackup.scss b/src/app/organisms/settings/KeyBackup.scss new file mode 100644 index 00000000..1f2b9b66 --- /dev/null +++ b/src/app/organisms/settings/KeyBackup.scss @@ -0,0 +1,27 @@ +.key-backup__create, +.key-backup__restore { + padding: var(--sp-normal); + + & > div { + padding: var(--sp-normal) 0; + display: flex; + align-items: center; + + & > .text { + margin: 0 var(--sp-normal); + } + } + + & > .text { + margin-bottom: var(--sp-normal); + } +} + +.key-backup__delete { + padding: var(--sp-normal); + padding-top: var(--sp-extra-loose); + + & > .text { + padding-bottom: var(--sp-normal); + } +} \ No newline at end of file diff --git a/src/app/organisms/settings/SecretStorageAccess.jsx b/src/app/organisms/settings/SecretStorageAccess.jsx new file mode 100644 index 00000000..f0131b14 --- /dev/null +++ b/src/app/organisms/settings/SecretStorageAccess.jsx @@ -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 ( +
+
+ + {error && {error}} + {!process && ( +
+ + {isPassphrase && } +
+ )} +
+ {process && } +
+ ); +} +SecretStorageAccess.propTypes = { + onComplete: PropTypes.func.isRequired, +}; + +/** + * @param {string} title Title of secret storage access dialog + * @returns {Promise} 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( + {title}, + (requestClose) => ( + { + handleComplete(keyData); + requestClose(requestClose); + }} + /> + ), + () => { + if (!isCompleted) resolve(null); + }, + ); +}); + +export default SecretStorageAccess; diff --git a/src/app/organisms/settings/SecretStorageAccess.scss b/src/app/organisms/settings/SecretStorageAccess.scss new file mode 100644 index 00000000..a7c0a9f6 --- /dev/null +++ b/src/app/organisms/settings/SecretStorageAccess.scss @@ -0,0 +1,20 @@ +.secret-storage-access { + padding: var(--sp-normal); + + & form > *:not(:first-child) { + margin-top: var(--sp-normal); + } + + & .text-b3 { + color: var(--tc-danger-high); + margin-top: var(--sp-ultra-tight) !important; + } + + &__btn { + display: flex; + justify-content: space-between; + } + & .donut-spinner { + margin-top: var(--sp-normal); + } +} \ No newline at end of file diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx index acfef5c9..6dbbffb2 100644 --- a/src/app/organisms/settings/Settings.jsx +++ b/src/app/organisms/settings/Settings.jsx @@ -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 (
- Session Info - - Use this session ID-key combo to verify or manage this session.} - /> + Cross signing and backup + +
- Encryption + Export/Import encryption keys