move device verification and megolm backup to new sdk
This commit is contained in:
parent
5cbc88fbdb
commit
1447772209
30 changed files with 927 additions and 815 deletions
|
|
@ -1,33 +1,33 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
|
||||
import { getUIAFlowForStages } from '../utils/matrix-uia';
|
||||
import { AuthResponse, AuthType, UIAFlow } from '../utils/matrix-uia';
|
||||
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
|
||||
import { UIAFlowOverlay } from './UIAFlowOverlay';
|
||||
import { PasswordStage, SSOStage } from './uia-stages';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
|
||||
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
|
||||
|
||||
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
|
||||
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
|
||||
if (passwordFlow) return passwordFlow;
|
||||
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
|
||||
};
|
||||
|
||||
type ActionUIAProps = {
|
||||
authData: IAuthData;
|
||||
authData: AuthResponse;
|
||||
ongoingFlow: UIAFlow;
|
||||
action: (authDict: AuthDict) => void;
|
||||
action: (authDict: object) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const completed = useUIACompleted(authData);
|
||||
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
|
||||
|
||||
const stageToComplete = getStageToComplete();
|
||||
|
||||
if (!stageToComplete) return null;
|
||||
|
||||
const ssoRedirectURL = new URL(`/auth/${AuthType.Sso}/fallback/web`, ct.session.baseUrl);
|
||||
|
||||
if (stageToComplete.session) {
|
||||
ssoRedirectURL.searchParams.set('session', stageToComplete.session);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIAFlowOverlay
|
||||
currentStep={completed.length + 1}
|
||||
|
|
@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
<PasswordStage
|
||||
userId={mx.getUserId()!}
|
||||
userId={ct.session.userId}
|
||||
stageData={stageToComplete}
|
||||
onCancel={onCancel}
|
||||
submitAuthDict={action}
|
||||
|
|
@ -44,7 +44,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
)}
|
||||
{stageToComplete.type === AuthType.Sso && stageToComplete.session && (
|
||||
<SSOStage
|
||||
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
|
||||
ssoRedirectURL={ssoRedirectURL.toString()}
|
||||
stageData={stageToComplete}
|
||||
onCancel={onCancel}
|
||||
submitAuthDict={action}
|
||||
|
|
@ -55,7 +55,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
}
|
||||
|
||||
type ActionUIAFlowsLoaderProps = {
|
||||
authData: IAuthData;
|
||||
authData: AuthResponse;
|
||||
unsupported: () => ReactNode;
|
||||
children: (ongoingFlow: UIAFlow) => ReactNode;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
|
|
@ -19,130 +17,113 @@ import {
|
|||
Text,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
|
||||
import { InfoCard } from './info-card';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import {
|
||||
useKeyBackupInfo,
|
||||
useKeyBackupStatus,
|
||||
useKeyBackupSync,
|
||||
useKeyBackupTrust,
|
||||
} from '../hooks/useKeyBackup';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
|
||||
import { bytesToSize } from '../utils/common';
|
||||
import { useObservableValue } from '../hooks/useObservableValue';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||
|
||||
type BackupStatusProps = {
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
function BackupStatus({ enabled }: BackupStatusProps) {
|
||||
function BackupStatus({ active }: BackupStatusProps) {
|
||||
return (
|
||||
<Box as="span" gap="100" alignItems="Center">
|
||||
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
|
||||
<Badge variant={active ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
|
||||
<Text
|
||||
as="span"
|
||||
size="L400"
|
||||
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
|
||||
style={{ color: active ? color.Success.Main : color.Critical.Main }}
|
||||
>
|
||||
{enabled ? 'Connected' : 'Disconnected'}
|
||||
{active ? 'Connected' : 'Disconnected'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
type BackupSyncingProps = {
|
||||
count: number;
|
||||
};
|
||||
function BackupSyncing({ count }: BackupSyncingProps) {
|
||||
|
||||
function BackupSyncing() {
|
||||
const ct = useClient();
|
||||
const [countState] = useAsyncCallbackValue(useCallback(() => ct.machine.roomKeys.count(), [ct]));
|
||||
|
||||
const count = countState.status === AsyncStatus.Success ? countState.data : undefined;
|
||||
|
||||
const remaining = count ? count.total - count.backedUp : undefined;
|
||||
|
||||
return (
|
||||
<Box as="span" gap="100" alignItems="Center">
|
||||
<Spinner size="50" variant="Primary" fill="Soft" />
|
||||
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
|
||||
Syncing ({count})
|
||||
Syncing ({remaining ?? '...'})
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupProgressFetching() {
|
||||
function BackupProgressFetching({
|
||||
receivedBytes,
|
||||
totalBytes,
|
||||
}: {
|
||||
receivedBytes: number;
|
||||
totalBytes?: number;
|
||||
}) {
|
||||
const receviedStr = bytesToSize(receivedBytes);
|
||||
const totalStr = totalBytes ? bytesToSize(totalBytes) : undefined;
|
||||
|
||||
return (
|
||||
<Box grow="Yes" gap="200" alignItems="Center">
|
||||
<Badge variant="Secondary" fill="Solid" radii="300">
|
||||
<Text size="L400">Restoring: 0%</Text>
|
||||
<Text size="L400">Downloading</Text>
|
||||
</Badge>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
|
||||
<ProgressBar
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
min={0}
|
||||
max={totalBytes}
|
||||
value={receivedBytes}
|
||||
/>
|
||||
</Box>
|
||||
<Spinner size="50" variant="Secondary" fill="Soft" />
|
||||
<Badge variant="Secondary" fill="Soft" radii="Pill">
|
||||
<Text size="L400">{totalStr ? `${receivedBytes} / ${totalStr}` : receviedStr}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type BackupProgressProps = {
|
||||
total: number;
|
||||
downloaded: number;
|
||||
total?: number;
|
||||
imported: number;
|
||||
};
|
||||
function BackupProgress({ total, downloaded }: BackupProgressProps) {
|
||||
function BackupProgress({ total, imported }: BackupProgressProps) {
|
||||
return (
|
||||
<Box grow="Yes" gap="200" alignItems="Center">
|
||||
<Badge variant="Secondary" fill="Solid" radii="300">
|
||||
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
|
||||
<Text size="L400">
|
||||
Restoring: {total ? `${Math.round(percent(0, total, imported))}%` : '0%'}
|
||||
</Text>
|
||||
</Badge>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
|
||||
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={imported} />
|
||||
</Box>
|
||||
<Badge variant="Secondary" fill="Soft" radii="Pill">
|
||||
<Text size="L400">
|
||||
{downloaded} / {total}
|
||||
</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type BackupTrustInfoProps = {
|
||||
crypto: CryptoApi;
|
||||
backupInfo: KeyBackupInfo;
|
||||
};
|
||||
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
|
||||
const trust = useKeyBackupTrust(crypto, backupInfo);
|
||||
|
||||
if (!trust) return null;
|
||||
|
||||
return (
|
||||
<Box direction="Column">
|
||||
{trust.matchesDecryptionKey ? (
|
||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||
<b>Backup has trusted decryption key.</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>Backup does not have trusted decryption key!</b>
|
||||
</Text>
|
||||
)}
|
||||
{trust.trusted ? (
|
||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||
<b>Backup has trusted by signature.</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>Backup does not have trusted signature!</b>
|
||||
</Text>
|
||||
{total && (
|
||||
<Badge variant="Secondary" fill="Soft" radii="Pill">
|
||||
<Text size="L400">
|
||||
{imported} / {total}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type BackupRestoreTileProps = {
|
||||
crypto: CryptoApi;
|
||||
};
|
||||
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
|
||||
const restoring =
|
||||
restoreProgress.status === BackupProgressStatus.Fetching ||
|
||||
restoreProgress.status === BackupProgressStatus.Loading;
|
||||
|
||||
const backupEnabled = useKeyBackupStatus(crypto);
|
||||
const backupInfo = useKeyBackupInfo(crypto);
|
||||
const [remainingSession, syncFailure] = useKeyBackupSync();
|
||||
export function BackupRestoreTile() {
|
||||
const ct = useClient();
|
||||
const backupInfo = useObservableValue(ct.backupInfo);
|
||||
const keyBackup = useObservableValue(ct.megolmBackup.keyBackup);
|
||||
const restoreState = useObservableValue(ct.megolmBackup.restoreState);
|
||||
const backupInProgress = useObservableValue(ct.megolmBackup.backupInProgress);
|
||||
const restoring = restoreState.type === 'download' || restoreState.type === 'import';
|
||||
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
|
|
@ -150,19 +131,11 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
|||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
await crypto.restoreKeyBackup({
|
||||
progressCallback(progress) {
|
||||
setRestoreProgress(progress);
|
||||
},
|
||||
});
|
||||
}, [crypto, setRestoreProgress])
|
||||
);
|
||||
|
||||
const handleRestore = () => {
|
||||
setMenuCords(undefined);
|
||||
restoreBackup();
|
||||
if (keyBackup) {
|
||||
ct.megolmBackup.restore(keyBackup);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -171,111 +144,99 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
|||
title="Encryption Backup"
|
||||
after={
|
||||
<Box alignItems="Center" gap="200">
|
||||
{remainingSession === 0 ? (
|
||||
<BackupStatus enabled={backupEnabled} />
|
||||
) : (
|
||||
<BackupSyncing count={remainingSession} />
|
||||
)}
|
||||
<IconButton
|
||||
aria-pressed={!!menuCords}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Icon size="100" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
{backupInfo && (
|
||||
<>
|
||||
{backupInProgress ? <BackupSyncing /> : <BackupStatus active={!!keyBackup} />}
|
||||
<IconButton
|
||||
aria-pressed={!!menuCords}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Menu
|
||||
style={{
|
||||
padding: config.space.S100,
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" gap="100">
|
||||
<Box direction="Column" gap="200">
|
||||
<InfoCard
|
||||
variant="SurfaceVariant"
|
||||
title="Backup Details"
|
||||
description={
|
||||
<>
|
||||
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
|
||||
<br />
|
||||
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
aria-disabled={restoreState.status === AsyncStatus.Loading || restoring}
|
||||
onClick={
|
||||
restoreState.status === AsyncStatus.Loading || restoring
|
||||
? undefined
|
||||
: handleRestore
|
||||
}
|
||||
before={<Icon size="100" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Restore Backup</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
<Icon size="100" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ padding: config.space.S100 }}>
|
||||
<Box direction="Column" gap="100">
|
||||
<Box direction="Column" gap="200">
|
||||
<InfoCard
|
||||
variant="SurfaceVariant"
|
||||
title="Backup Details"
|
||||
description={
|
||||
<>
|
||||
<span>Version: {backupInfo.version}</span>
|
||||
<br />
|
||||
<span>Keys: {backupInfo.count}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
aria-disabled={!keyBackup || restoring}
|
||||
onClick={restoring ? undefined : handleRestore}
|
||||
before={<Icon size="100" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Restore Backup</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{syncFailure && (
|
||||
{!backupInfo && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{syncFailure}</b>
|
||||
<b>No key backup has been configured.</b>
|
||||
</Text>
|
||||
)}
|
||||
{!backupEnabled && backupInfo === null && (
|
||||
|
||||
{backupInfo && !keyBackup && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>No backup present on server!</b>
|
||||
<b>Cannot start key backup because the backup could not be verified.</b>
|
||||
</Text>
|
||||
)}
|
||||
{!syncFailure && !backupEnabled && backupInfo && (
|
||||
<BackupTrustInfo crypto={crypto} backupInfo={backupInfo} />
|
||||
)}
|
||||
{restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />}
|
||||
{restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
|
||||
{restoreProgress.status === BackupProgressStatus.Loading && (
|
||||
<BackupProgress
|
||||
total={restoreProgress.data.total}
|
||||
downloaded={restoreProgress.data.downloaded}
|
||||
{restoreState.type === 'download' && (
|
||||
<BackupProgressFetching
|
||||
receivedBytes={restoreState.receivedBytes}
|
||||
totalBytes={restoreState.totalBytes}
|
||||
/>
|
||||
)}
|
||||
{restoreState.status === AsyncStatus.Error && (
|
||||
{restoreState.type === 'import' && (
|
||||
<BackupProgress
|
||||
imported={restoreState.progress?.imported ?? 0}
|
||||
total={restoreState.progress?.total}
|
||||
/>
|
||||
)}
|
||||
{restoreState.type === 'error' && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{restoreState.error.message}</b>
|
||||
<b>{restoreState.error.data.error ?? restoreState.error.data.errcode}</b>
|
||||
</Text>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoRestoreBackupOnVerification() {
|
||||
useRestoreBackupOnVerification();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../hooks/useDeviceVerificationStatus';
|
||||
|
||||
type DeviceVerificationStatusProps = {
|
||||
crypto?: CryptoApi;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
children: (verificationStatus: VerificationStatus) => ReactNode;
|
||||
};
|
||||
|
||||
export function DeviceVerificationStatus({
|
||||
crypto,
|
||||
userId,
|
||||
deviceId,
|
||||
children,
|
||||
}: DeviceVerificationStatusProps) {
|
||||
const status = useDeviceVerificationStatus(crypto, userId, deviceId);
|
||||
const status = useDeviceVerificationStatus(userId, deviceId);
|
||||
|
||||
return children(status);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import React, { forwardRef, useCallback } from 'react';
|
|||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { InfoCard } from './info-card';
|
||||
import { useCrossSigning } from '../hooks/useCrossSigning';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../hooks/useDeviceVerificationStatus';
|
||||
|
||||
type LogoutDialogProps = {
|
||||
handleClose: () => void;
|
||||
|
|
@ -9,6 +15,10 @@ type LogoutDialogProps = {
|
|||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
({ handleClose }, ref) => {
|
||||
const ct = useClient();
|
||||
const hasEncryptedRoom = ct.encryptedRooms.read.size > 0;
|
||||
const crossSigning = useCrossSigning();
|
||||
|
||||
const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
|
|
@ -33,8 +43,8 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||
</Box>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{/* {hasEncryptedRoom &&
|
||||
(crossSigningActive ? (
|
||||
{hasEncryptedRoom &&
|
||||
(crossSigning ? (
|
||||
verificationStatus === VerificationStatus.Unverified && (
|
||||
<InfoCard
|
||||
variant="Critical"
|
||||
|
|
@ -48,7 +58,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||
title="Alert"
|
||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||
/>
|
||||
))} */}
|
||||
))}
|
||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
|
|
|
|||
|
|
@ -13,13 +13,15 @@ import {
|
|||
color,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { AesHmacSha2, AesHmacSha2KeyDescription } from 'neurone/lib/crypto/ssss';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { SettingTile } from './setting-tile';
|
||||
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
|
||||
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { storePrivateKey } from '../../client/secretStorageKeys';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { getCrossSigningPrivateKeys, useCrossSigningSecretData } from '../hooks/useCrossSigning';
|
||||
import { AccountDataType } from '../../types/matrix/accountData';
|
||||
|
||||
export enum ManualVerificationMethod {
|
||||
RecoveryPassphrase = 'passphrase',
|
||||
|
|
@ -112,7 +114,7 @@ export function ManualVerificationMethodSwitcher({
|
|||
|
||||
type ManualVerificationTileProps = {
|
||||
secretStorageKeyId: string;
|
||||
secretStorageKeyContent: SecretStorageKeyContent;
|
||||
secretStorageKeyContent: AesHmacSha2KeyDescription;
|
||||
options?: ReactNode;
|
||||
};
|
||||
export function ManualVerificationTile({
|
||||
|
|
@ -120,7 +122,8 @@ export function ManualVerificationTile({
|
|||
secretStorageKeyContent,
|
||||
options,
|
||||
}: ManualVerificationTileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const csSecretData = useCrossSigningSecretData(secretStorageKeyId);
|
||||
|
||||
const hasPassphrase = !!secretStorageKeyContent.passphrase;
|
||||
const [method, setMethod] = useState(
|
||||
|
|
@ -130,25 +133,53 @@ export function ManualVerificationTile({
|
|||
);
|
||||
|
||||
const verifyAndRestoreBackup = useCallback(
|
||||
async (recoveryKey: Uint8Array) => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) {
|
||||
throw new Error('Unexpected Error! Crypto object not found.');
|
||||
async (recoveryKey: Uint8Array<ArrayBuffer>) => {
|
||||
const status = await ct.machine.crossSigning.getStatus();
|
||||
const machineHasKeys = status.master && status.self && status.user;
|
||||
|
||||
if (!machineHasKeys && csSecretData) {
|
||||
const crossSigningKeys = await getCrossSigningPrivateKeys(recoveryKey, csSecretData);
|
||||
|
||||
const s = await ct.machine.crossSigning.importKeys(crossSigningKeys);
|
||||
if (!s.master || !s.self || !s.user) {
|
||||
throw new Error('Failed to import to CrossSigningSecrets keys!');
|
||||
}
|
||||
}
|
||||
|
||||
storePrivateKey(secretStorageKeyId, recoveryKey);
|
||||
const { userId, deviceId } = ct.session;
|
||||
const device = await ct.machine.userTracker.getDevice(userId, deviceId);
|
||||
if (device && !device.crossSigningTrusted) {
|
||||
const resp = await device.verify();
|
||||
const [err] = await parseMatrixResp(resp);
|
||||
if (err) {
|
||||
throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.');
|
||||
}
|
||||
|
||||
await crypto.bootstrapCrossSigning({});
|
||||
await crypto.bootstrapSecretStorage({});
|
||||
// device.verify uploads signatures to mark device as verifed
|
||||
// query keys to receive signs now instead of update via /sync
|
||||
// if we don't do that device remains unverified until next sync device_list change
|
||||
await ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
|
||||
}
|
||||
|
||||
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
||||
const backupKeyEncryptedData = ct.secrets.megolmBackup.read?.get(secretStorageKeyId);
|
||||
if (!backupKeyEncryptedData) {
|
||||
return;
|
||||
}
|
||||
const backupKey = await AesHmacSha2.decrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.MegolmBackupV1,
|
||||
backupKeyEncryptedData
|
||||
);
|
||||
await ct.megolmBackup.saveDecryptionKey(backupKey);
|
||||
},
|
||||
[mx, secretStorageKeyId]
|
||||
[ct, secretStorageKeyId, csSecretData]
|
||||
);
|
||||
|
||||
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
|
||||
verifyAndRestoreBackup
|
||||
);
|
||||
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<
|
||||
void,
|
||||
Error,
|
||||
[Uint8Array<ArrayBuffer>]
|
||||
>(verifyAndRestoreBackup);
|
||||
const verifying = verifyState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
|
|
@ -174,7 +205,7 @@ export function ManualVerificationTile({
|
|||
{method === ManualVerificationMethod.RecoveryKey && (
|
||||
<SecretStorageRecoveryKey
|
||||
processing={verifying}
|
||||
keyContent={secretStorageKeyContent}
|
||||
keyDescription={secretStorageKeyContent}
|
||||
onDecodedRecoveryKey={handleDecodedRecoveryKey}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -182,8 +213,8 @@ export function ManualVerificationTile({
|
|||
secretStorageKeyContent.passphrase && (
|
||||
<SecretStorageRecoveryPassphrase
|
||||
processing={verifying}
|
||||
keyContent={secretStorageKeyContent}
|
||||
passphraseContent={secretStorageKeyContent.passphrase}
|
||||
keyDescription={secretStorageKeyContent}
|
||||
passphraseInfo={secretStorageKeyContent.passphrase}
|
||||
onDecodedRecoveryKey={handleDecodedRecoveryKey}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
MSticker,
|
||||
MText,
|
||||
MVideo,
|
||||
RedactedContent,
|
||||
UnsupportedContent,
|
||||
} from './message';
|
||||
import { MsgType } from '../../types/matrix/common';
|
||||
|
|
@ -31,7 +30,6 @@ type RenderMessageContentProps = {
|
|||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
redacted?: boolean;
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
|
|
@ -45,10 +43,7 @@ export function RenderMessageContent({
|
|||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
redacted,
|
||||
}: RenderMessageContentProps) {
|
||||
if (redacted) return <RedactedContent />;
|
||||
|
||||
if (eventType === MessageEvent.Sticker) {
|
||||
return (
|
||||
<MSticker
|
||||
|
|
|
|||
|
|
@ -1,53 +1,51 @@
|
|||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { PasswordInput } from './password-input';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import {
|
||||
SecretStorageKeyContent,
|
||||
SecretStoragePassphraseContent,
|
||||
} from '../../types/matrix/accountData';
|
||||
AesHmacSha2,
|
||||
AesHmacSha2KeyDescription,
|
||||
Passphrase,
|
||||
PassphraseInfo,
|
||||
} from 'neurone/lib/crypto/ssss';
|
||||
import { PasswordInput } from './password-input';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
|
||||
type SecretStorageRecoveryPassphraseProps = {
|
||||
processing?: boolean;
|
||||
keyContent: SecretStorageKeyContent;
|
||||
passphraseContent: SecretStoragePassphraseContent;
|
||||
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
|
||||
keyDescription: AesHmacSha2KeyDescription;
|
||||
passphraseInfo: PassphraseInfo;
|
||||
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
|
||||
};
|
||||
export function SecretStorageRecoveryPassphrase({
|
||||
processing,
|
||||
keyContent,
|
||||
passphraseContent,
|
||||
keyDescription,
|
||||
passphraseInfo,
|
||||
onDecodedRecoveryKey,
|
||||
}: SecretStorageRecoveryPassphraseProps) {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const [driveKeyState, submitPassphrase] = useAsyncCallback<
|
||||
Uint8Array,
|
||||
Uint8Array<ArrayBuffer>,
|
||||
Error,
|
||||
Parameters<typeof deriveRecoveryKeyFromPassphrase>
|
||||
[passphrase: string]
|
||||
>(
|
||||
useCallback(
|
||||
async (passphrase, salt, iterations, bits) => {
|
||||
const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
|
||||
passphrase,
|
||||
salt,
|
||||
iterations,
|
||||
bits
|
||||
async (passphrase) => {
|
||||
const decodedRecoveryKey = await Passphrase.derive(passphrase, passphraseInfo);
|
||||
|
||||
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
|
||||
decodedRecoveryKey,
|
||||
keyDescription.iv
|
||||
);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
|
||||
if (!match) {
|
||||
if (regeneratedMac !== keyDescription.mac) {
|
||||
throw new Error('Invalid recovery passphrase.');
|
||||
}
|
||||
|
||||
return decodedRecoveryKey;
|
||||
},
|
||||
[mx, keyContent]
|
||||
[keyDescription, passphraseInfo]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -64,8 +62,7 @@ export function SecretStorageRecoveryPassphrase({
|
|||
const recoveryPassphrase = recoveryPassphraseInput.value.trim();
|
||||
if (!recoveryPassphrase) return;
|
||||
|
||||
const { salt, iterations, bits } = passphraseContent;
|
||||
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
|
||||
submitPassphrase(recoveryPassphrase).then((decodedRecoveryKey) => {
|
||||
if (alive()) {
|
||||
recoveryPassphraseInput.value = '';
|
||||
onDecodedRecoveryKey(decodedRecoveryKey);
|
||||
|
|
@ -115,31 +112,37 @@ export function SecretStorageRecoveryPassphrase({
|
|||
|
||||
type SecretStorageRecoveryKeyProps = {
|
||||
processing?: boolean;
|
||||
keyContent: SecretStorageKeyContent;
|
||||
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
|
||||
keyDescription: AesHmacSha2KeyDescription;
|
||||
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
|
||||
};
|
||||
export function SecretStorageRecoveryKey({
|
||||
processing,
|
||||
keyContent,
|
||||
keyDescription,
|
||||
onDecodedRecoveryKey,
|
||||
}: SecretStorageRecoveryKeyProps) {
|
||||
const mx = useMatrixClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
|
||||
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<
|
||||
Uint8Array<ArrayBuffer>,
|
||||
Error,
|
||||
[string]
|
||||
>(
|
||||
useCallback(
|
||||
async (recoveryKey) => {
|
||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
|
||||
decodedRecoveryKey,
|
||||
keyDescription.iv
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
if (regeneratedMac !== keyDescription.mac) {
|
||||
throw new Error('Invalid recovery key.');
|
||||
}
|
||||
|
||||
return decodedRecoveryKey;
|
||||
},
|
||||
[mx, keyContent]
|
||||
[keyDescription]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { RenderMessageContent } from '../RenderMessageContent';
|
|||
import { Time } from './Time';
|
||||
import { BubbleLayout, CompactLayout, MessageBase, ModernLayout } from './layout';
|
||||
import { MessageLayout, MessageSpacing } from '../../state/settings';
|
||||
import { RedactedContent } from './MsgTypeRenderers';
|
||||
|
||||
type ReplyMessageProps = {
|
||||
username?: ReactNode;
|
||||
|
|
@ -118,6 +119,8 @@ export function Reply({
|
|||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
|
||||
|
||||
const redactedEvent = redacted || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
|
||||
|
||||
return (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<ReplyMessage
|
||||
|
|
@ -142,23 +145,24 @@ export function Reply({
|
|||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
|
||||
{replyEvent === null ? (
|
||||
<Text>
|
||||
<MessageBadEncryptedContent />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={getMemberName(replyEvent.sender)}
|
||||
eventType={replyEvent.type}
|
||||
ts={replyEvent.origin_server_ts}
|
||||
edited={false}
|
||||
content={replyEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
redacted={redacted || !!rUnsigned.redactedBecause(replyEvent.unsigned)}
|
||||
/>
|
||||
)}
|
||||
{redactedEvent && <RedactedContent />}
|
||||
{!redactedEvent &&
|
||||
(replyEvent === null ? (
|
||||
<Text>
|
||||
<MessageBadEncryptedContent />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={getMemberName(replyEvent.sender)}
|
||||
eventType={replyEvent.type}
|
||||
ts={replyEvent.origin_server_ts}
|
||||
edited={false}
|
||||
content={replyEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<LinePlaceholder
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
CompactPlaceholder,
|
||||
MessageBase,
|
||||
Reply,
|
||||
RedactedContent,
|
||||
} from '../../components/message';
|
||||
import {
|
||||
factoryRenderLinkifyWithMention,
|
||||
|
|
@ -443,47 +444,47 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
|
|||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
>
|
||||
{(editedEvent ?? event).type === MessageEvent.RoomMessageEncrypted ? (
|
||||
<EncryptedMessageEvent
|
||||
roomId={roomId}
|
||||
decryptionManager={ct.decryption}
|
||||
event={editedEvent ?? event}
|
||||
>
|
||||
{(decryptedEvent) => (
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(event.sender)}
|
||||
eventType={decryptedEvent.type}
|
||||
ts={event.origin_server_ts}
|
||||
edited={!!editedEvent}
|
||||
content={rContent.newContent(decryptedEvent.content) ?? decryptedEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
redacted={redacted.has(event.event_id)}
|
||||
/>
|
||||
)}
|
||||
</EncryptedMessageEvent>
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(event.sender)}
|
||||
eventType={event.type}
|
||||
ts={event.origin_server_ts}
|
||||
edited={!!editedEvent}
|
||||
content={
|
||||
editedEvent
|
||||
? rContent.newContent(editedEvent.content) ?? editedEvent.content
|
||||
: event.content
|
||||
}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
redacted={redacted.has(event.event_id)}
|
||||
/>
|
||||
)}
|
||||
{redacted.has(event.event_id) && <RedactedContent />}
|
||||
{!redacted.has(event.event_id) &&
|
||||
((editedEvent ?? event).type === MessageEvent.RoomMessageEncrypted ? (
|
||||
<EncryptedMessageEvent
|
||||
roomId={roomId}
|
||||
decryptionManager={ct.decryption}
|
||||
event={editedEvent ?? event}
|
||||
>
|
||||
{(decryptedEvent) => (
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(event.sender)}
|
||||
eventType={decryptedEvent.type}
|
||||
ts={event.origin_server_ts}
|
||||
edited={!!editedEvent}
|
||||
content={rContent.newContent(decryptedEvent.content) ?? decryptedEvent.content}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
)}
|
||||
</EncryptedMessageEvent>
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={membersAPI.getName(event.sender)}
|
||||
eventType={event.type}
|
||||
ts={event.origin_server_ts}
|
||||
edited={!!editedEvent}
|
||||
content={
|
||||
editedEvent
|
||||
? rContent.newContent(editedEvent.content) ?? editedEvent.content
|
||||
: event.content
|
||||
}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
/>
|
||||
))}
|
||||
{reactionJSX}
|
||||
{threadTimeline && (
|
||||
<ThreadSelectorContainer>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FormEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { FormEventHandler, ReactNode, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
|
|
@ -15,11 +15,11 @@ import {
|
|||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { Device } from 'neurone/lib/api/types';
|
||||
import { putDevice } from 'neurone/lib/api/matrix/client/v3/devices';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
|
|
@ -29,6 +29,8 @@ import { LogoutDialog } from '../../../components/LogoutDialog';
|
|||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
|
|
@ -61,7 +63,7 @@ function DeviceActiveTime({ ts }: { ts: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
function DeviceDetails({ device }: { device: IMyDevice }) {
|
||||
function DeviceDetails({ device }: { device: Device }) {
|
||||
return (
|
||||
<>
|
||||
{typeof device.device_id === 'string' && (
|
||||
|
|
@ -78,58 +80,52 @@ function DeviceDetails({ device }: { device: IMyDevice }) {
|
|||
);
|
||||
}
|
||||
|
||||
type DeviceKeyDetailsProps = {
|
||||
crypto: CryptoApi;
|
||||
};
|
||||
export function DeviceKeyDetails({ crypto }: DeviceKeyDetailsProps) {
|
||||
const [keysState, loadKeys] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const keys = crypto.getOwnDeviceKeys();
|
||||
return keys;
|
||||
}, [crypto])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
if (keysState.status === AsyncStatus.Error) return null;
|
||||
export function DeviceKeyDetails() {
|
||||
const ct = useClient();
|
||||
|
||||
return (
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
Device Key:{' '}
|
||||
<i>{keysState.status === AsyncStatus.Success ? keysState.data.ed25519 : 'loading...'}</i>
|
||||
</Text>
|
||||
<>
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
Encryption Key: <i>{ct.machine.getEncryptionKey()}</i>
|
||||
</Text>
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
Singing Key: <i>{ct.machine.getSigningKey()}</i>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DeviceRenameProps = {
|
||||
device: IMyDevice;
|
||||
device: Device;
|
||||
onCancel: () => void;
|
||||
onRename: () => void;
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
};
|
||||
function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceRenameProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const alive = useAlive();
|
||||
|
||||
const [renameState, rename] = useAsyncCallback<void, MatrixError, [string]>(
|
||||
const [renameState, rename] = useAsyncCallback<void, unknown, [string]>(
|
||||
useCallback(
|
||||
async (name: string) => {
|
||||
await mx.setDeviceDetails(device.device_id, { display_name: name });
|
||||
const resp = await ct.requestAuthJSON(
|
||||
putDevice({
|
||||
pathParams: { deviceId: device.device_id },
|
||||
body: {
|
||||
display_name: name,
|
||||
},
|
||||
})
|
||||
);
|
||||
const [err] = await parseMatrixResp(resp);
|
||||
if (err) throw new Error(err.data.error ?? err.data.errcode);
|
||||
await refreshDeviceList();
|
||||
},
|
||||
[mx, device.device_id, refreshDeviceList]
|
||||
[ct, device.device_id, refreshDeviceList]
|
||||
)
|
||||
);
|
||||
|
||||
const renaming = renameState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
if (renameState.status === AsyncStatus.Success) {
|
||||
onRename();
|
||||
}
|
||||
}, [renameState, onRename]);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (renaming) return;
|
||||
|
|
@ -140,7 +136,11 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
|
|||
const deviceName = nameInput.value.trim();
|
||||
if (!deviceName || deviceName === device.display_name) return;
|
||||
|
||||
rename(deviceName);
|
||||
rename(deviceName).then(() => {
|
||||
if (alive()) {
|
||||
onRename();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -186,7 +186,7 @@ function DeviceRename({ device, onCancel, onRename, refreshDeviceList }: DeviceR
|
|||
</Box>
|
||||
{renameState.status === AsyncStatus.Error ? (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{renameState.error.message}
|
||||
{renameState.error instanceof Error ? renameState.error.message : 'Unknown Error'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">Device names are visible to public.</Text>
|
||||
|
|
@ -260,7 +260,7 @@ export function DeviceDeleteBtn({
|
|||
}
|
||||
|
||||
type DeviceTileProps = {
|
||||
device: IMyDevice;
|
||||
device: Device;
|
||||
deleted?: boolean;
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { LocalBackup } from './LocalBackup';
|
||||
import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile';
|
||||
import { OtherDevices } from './OtherDevices';
|
||||
import {
|
||||
DeviceVerificationOptions,
|
||||
EnableVerification,
|
||||
|
|
@ -24,8 +22,11 @@ import {
|
|||
useSecretStorageDefaultKeyId,
|
||||
useSecretStorageKeyContent,
|
||||
} from '../../../hooks/useSecretStorage';
|
||||
import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
|
||||
import { useCrossSigning } from '../../../hooks/useCrossSigning';
|
||||
import { BackupRestoreTile } from '../../../components/BackupRestore';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
import { OtherDevices } from './OtherDevices';
|
||||
import { useObservableValue } from '../../../hooks/useObservableValue';
|
||||
|
||||
function DevicesPlaceholder() {
|
||||
return (
|
||||
|
|
@ -40,24 +41,20 @@ type DevicesProps = {
|
|||
requestClose: () => void;
|
||||
};
|
||||
export function Devices({ requestClose }: DevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const ct = useClient();
|
||||
const crossSigning = useCrossSigning();
|
||||
const keyBackup = useObservableValue(ct.megolmBackup.keyBackup);
|
||||
|
||||
const [devices, refreshDeviceList] = useDeviceList();
|
||||
|
||||
const [currentDevice, otherDevices] = useSplitCurrentDevice(devices);
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
ct.session.userId,
|
||||
currentDevice?.device_id
|
||||
);
|
||||
|
||||
const otherDevicesId = useDeviceIds(otherDevices);
|
||||
const unverifiedDeviceCount = useUnverifiedDeviceCount(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
otherDevicesId
|
||||
);
|
||||
const unverifiedDeviceCount = useUnverifiedDeviceCount(ct.session.userId, otherDevicesId);
|
||||
|
||||
const defaultSecretStorageKeyId = useSecretStorageDefaultKeyId();
|
||||
const defaultSecretStorageKeyContent = useSecretStorageKeyContent(
|
||||
|
|
@ -96,18 +93,17 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
after={
|
||||
<>
|
||||
<EnableVerification visible={!crossSigningActive} />
|
||||
{crossSigningActive && (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<VerificationStatusBadge
|
||||
verificationStatus={verificationStatus}
|
||||
otherUnverifiedCount={unverifiedDeviceCount}
|
||||
/>
|
||||
<DeviceVerificationOptions />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
crossSigning ? (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<VerificationStatusBadge
|
||||
verificationStatus={verificationStatus}
|
||||
otherUnverifiedCount={unverifiedDeviceCount}
|
||||
/>
|
||||
<DeviceVerificationOptions />
|
||||
</Box>
|
||||
) : (
|
||||
<EnableVerification />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
|
@ -126,9 +122,9 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
refreshDeviceList={refreshDeviceList}
|
||||
options={<DeviceLogoutBtn />}
|
||||
>
|
||||
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
||||
<DeviceKeyDetails />
|
||||
</DeviceTile>
|
||||
{crossSigningActive &&
|
||||
{crossSigning &&
|
||||
verificationStatus === VerificationStatus.Unverified &&
|
||||
defaultSecretStorageKeyId &&
|
||||
defaultSecretStorageKeyContent && (
|
||||
|
|
@ -137,9 +133,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
||||
/>
|
||||
)}
|
||||
{crypto && verificationStatus === VerificationStatus.Verified && (
|
||||
<BackupRestoreTile crypto={crypto} />
|
||||
)}
|
||||
{keyBackup && <BackupRestoreTile />}
|
||||
</SequenceCard>
|
||||
) : (
|
||||
<DeviceTilePlaceholder />
|
||||
|
|
@ -151,7 +145,7 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
devices={otherDevices}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
showVerification={
|
||||
crossSigningActive && verificationStatus === VerificationStatus.Verified
|
||||
crossSigning && verificationStatus === VerificationStatus.Verified
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixResponseResult, parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { Device } from 'neurone/lib/api/types';
|
||||
import { postDeleteDevices } from 'neurone/lib/api/matrix/client/v3/deleteDevices';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
|
||||
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
|
||||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
||||
import { VerifyOtherDeviceTile } from './Verification';
|
||||
|
|
@ -15,15 +16,15 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
|||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
type OtherDevicesProps = {
|
||||
devices: IMyDevice[];
|
||||
devices: Device[];
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
showVerification?: boolean;
|
||||
};
|
||||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
const ct = useClient();
|
||||
const authMetadata = useAuthMetadata();
|
||||
const accountManagementActions = useAccountManagementActions();
|
||||
|
||||
|
|
@ -69,31 +70,40 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||
});
|
||||
}, []);
|
||||
|
||||
const [deleteState, setDeleteState] = useState<AsyncState<void, MatrixError>>({
|
||||
const [deleteState, setDeleteState] = useState<
|
||||
AsyncState<MatrixResponseResult<unknown>, unknown>
|
||||
>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
|
||||
const deleteDevices = useAsync(
|
||||
useCallback(
|
||||
async (authDict?: AuthDict) => {
|
||||
await mx.deleteMultipleDevices(Array.from(deleted), authDict);
|
||||
async (authDict?: object) => {
|
||||
const resp = await ct.requestAuthJSON(
|
||||
postDeleteDevices({ body: { auth: authDict, devices: Array.from(deleted) } })
|
||||
);
|
||||
return parseMatrixResp(resp);
|
||||
},
|
||||
[mx, deleted]
|
||||
[ct, deleted]
|
||||
),
|
||||
useCallback(
|
||||
(state: typeof deleteState) => {
|
||||
if (state.status === AsyncStatus.Success) {
|
||||
setDeleted(new Set());
|
||||
refreshDeviceList();
|
||||
const [err] = state.data;
|
||||
if (!err) {
|
||||
setDeleted(new Set());
|
||||
refreshDeviceList();
|
||||
}
|
||||
}
|
||||
setDeleteState(state);
|
||||
},
|
||||
[refreshDeviceList]
|
||||
)
|
||||
);
|
||||
const [authData, deleteError] = useUIAMatrixError(
|
||||
deleteState.status === AsyncStatus.Error ? deleteState.error : undefined
|
||||
);
|
||||
|
||||
const deleteError = deleteState.status === AsyncStatus.Error ? deleteState.error : undefined;
|
||||
const [deleteRespError] = deleteState.status === AsyncStatus.Success ? deleteState.data : [];
|
||||
const [authData, deleteMatrixError] = useUIAMatrixError(deleteRespError);
|
||||
const deleting = deleteState.status === AsyncStatus.Loading || authData !== undefined;
|
||||
|
||||
const handleCancelDelete = () => setDeleted(new Set());
|
||||
|
|
@ -166,14 +176,10 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||
}
|
||||
/>
|
||||
{showVerification && crypto && (
|
||||
<DeviceVerificationStatus
|
||||
crypto={crypto}
|
||||
userId={mx.getSafeUserId()}
|
||||
deviceId={device.device_id}
|
||||
>
|
||||
<DeviceVerificationStatus userId={ct.session.userId} deviceId={device.device_id}>
|
||||
{(status) =>
|
||||
status === VerificationStatus.Unverified && (
|
||||
<VerifyOtherDeviceTile crypto={crypto} deviceId={device.device_id} />
|
||||
<VerifyOtherDeviceTile deviceId={device.device_id} />
|
||||
)
|
||||
}
|
||||
</DeviceVerificationStatus>
|
||||
|
|
@ -196,9 +202,14 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||
>
|
||||
<Box alignItems="Center" gap="400">
|
||||
<Box grow="Yes" direction="Column">
|
||||
{deleteError ? (
|
||||
{deleteError || deleteMatrixError ? (
|
||||
<Text size="T200">
|
||||
<b>Failed to logout devices! Please try again. {deleteError.message}</b>
|
||||
<b>
|
||||
Failed to logout devices! Please try again.{' '}
|
||||
{deleteError instanceof Error
|
||||
? deleteError.message
|
||||
: deleteMatrixError?.data.error ?? 'Unkonwn Error'}
|
||||
</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">
|
||||
|
|
|
|||
|
|
@ -19,13 +19,12 @@ import {
|
|||
MenuItem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { CryptoApi, VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { InboundVerification, OutboundVerification } from 'neurone/lib/crypto/machine/verification';
|
||||
import { AesHmacSha2KeyDescription } from 'neurone/lib/crypto/ssss';
|
||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
||||
import { InfoCard } from '../../../components/info-card';
|
||||
import { ManualVerificationTile } from '../../../components/ManualVerification';
|
||||
import { SecretStorageKeyContent } from '../../../../types/matrix/accountData';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { DeviceVerification } from '../../../components/DeviceVerification';
|
||||
import {
|
||||
DeviceVerificationReset,
|
||||
|
|
@ -35,6 +34,7 @@ import { stopPropagation } from '../../../utils/keyboard';
|
|||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||
import { withSearchParam } from '../../../pages/pathUtils';
|
||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
type VerificationStatusBadgeProps = {
|
||||
verificationStatus: VerificationStatus;
|
||||
|
|
@ -98,7 +98,7 @@ function LearnStartVerificationFromOtherDevice() {
|
|||
|
||||
type VerifyCurrentDeviceTileProps = {
|
||||
secretStorageKeyId: string;
|
||||
secretStorageKeyContent: SecretStorageKeyContent;
|
||||
secretStorageKeyContent: AesHmacSha2KeyDescription;
|
||||
};
|
||||
export function VerifyCurrentDeviceTile({
|
||||
secretStorageKeyId,
|
||||
|
|
@ -163,20 +163,25 @@ export function VerifyCurrentDeviceTile({
|
|||
}
|
||||
|
||||
type VerifyOtherDeviceTileProps = {
|
||||
crypto: CryptoApi;
|
||||
deviceId: string;
|
||||
};
|
||||
export function VerifyOtherDeviceTile({ crypto, deviceId }: VerifyOtherDeviceTileProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [requestState, setRequestState] = useState<AsyncState<VerificationRequest, Error>>({
|
||||
export function VerifyOtherDeviceTile({ deviceId }: VerifyOtherDeviceTileProps) {
|
||||
const ct = useClient();
|
||||
const [requestState, setRequestState] = useState<
|
||||
AsyncState<InboundVerification | OutboundVerification, Error>
|
||||
>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
|
||||
const requestVerification = useAsync<VerificationRequest, Error, []>(
|
||||
useCallback(() => {
|
||||
const requestPromise = crypto.requestDeviceVerification(mx.getSafeUserId(), deviceId);
|
||||
return requestPromise;
|
||||
}, [mx, crypto, deviceId]),
|
||||
const requestVerification = useAsync(
|
||||
useCallback(async () => {
|
||||
const r = await ct.machine.verificationManager.requestDeviceVerification(
|
||||
ct.session.userId,
|
||||
deviceId,
|
||||
['m.sas.v1']
|
||||
);
|
||||
return r;
|
||||
}, [ct, deviceId]),
|
||||
setRequestState
|
||||
);
|
||||
|
||||
|
|
@ -217,23 +222,18 @@ export function VerifyOtherDeviceTile({ crypto, deviceId }: VerifyOtherDeviceTil
|
|||
);
|
||||
}
|
||||
|
||||
type EnableVerificationProps = {
|
||||
visible: boolean;
|
||||
};
|
||||
export function EnableVerification({ visible }: EnableVerificationProps) {
|
||||
export function EnableVerification() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleCancel = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
<Button size="300" radii="300" onClick={() => setOpen(true)}>
|
||||
<Text as="span" size="B300">
|
||||
Enable
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="300" radii="300" onClick={() => setOpen(true)}>
|
||||
<Text as="span" size="B300">
|
||||
Enable
|
||||
</Text>
|
||||
</Button>
|
||||
{open && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,62 @@
|
|||
import { AccountDataEvent, SecretAccountData } from '../../types/matrix/accountData';
|
||||
import { useAccountData } from './useAccountData';
|
||||
import { AesHmacSha2, AesHmacSha2EncryptedData } from 'neurone/lib/crypto/ssss';
|
||||
import { useMemo } from 'react';
|
||||
import { CrossSigningKeys } from 'neurone/lib/crypto/machine';
|
||||
import { CrossSigningSecrets } from '../../client/context';
|
||||
import { useClient } from './useClient';
|
||||
import { useObservableValue } from './useObservableValue';
|
||||
import { AccountDataType } from '../../types/matrix/accountData';
|
||||
|
||||
export const useCrossSigningActive = (): boolean => {
|
||||
const masterEvent = useAccountData(AccountDataEvent.CrossSigningMaster);
|
||||
const content = masterEvent?.getContent<SecretAccountData>();
|
||||
export const useCrossSigning = (): CrossSigningSecrets | undefined => {
|
||||
const ct = useClient();
|
||||
|
||||
return !!content;
|
||||
const crossSigning = useObservableValue(ct.secrets.crossSigning);
|
||||
|
||||
return crossSigning;
|
||||
};
|
||||
|
||||
export type CrossSigningSecretData = {
|
||||
master: AesHmacSha2EncryptedData;
|
||||
self: AesHmacSha2EncryptedData;
|
||||
user: AesHmacSha2EncryptedData;
|
||||
};
|
||||
export const useCrossSigningSecretData = (keyId: string): CrossSigningSecretData | undefined => {
|
||||
const crossSigning = useCrossSigning();
|
||||
const data: CrossSigningSecretData | undefined = useMemo(() => {
|
||||
const master = crossSigning?.master.get(keyId);
|
||||
const self = crossSigning?.self.get(keyId);
|
||||
const user = crossSigning?.user.get(keyId);
|
||||
|
||||
if (!master || !self || !user) return undefined;
|
||||
|
||||
return {
|
||||
master,
|
||||
self,
|
||||
user,
|
||||
};
|
||||
}, [crossSigning, keyId]);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCrossSigningPrivateKeys = async (
|
||||
recoveryKey: Uint8Array<ArrayBuffer>,
|
||||
secretData: CrossSigningSecretData
|
||||
): Promise<CrossSigningKeys> => {
|
||||
const master = await AesHmacSha2.decrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningMaster,
|
||||
secretData.master
|
||||
);
|
||||
const self = await AesHmacSha2.decrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningSelf,
|
||||
secretData.self
|
||||
);
|
||||
const user = await AesHmacSha2.decrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningUser,
|
||||
secretData.user
|
||||
);
|
||||
|
||||
return { master, self, user };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,30 +1,29 @@
|
|||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import { IMyDevice } from 'matrix-js-sdk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { Device } from 'neurone/lib/api/types';
|
||||
import { getDevices, GetDevicesOK } from 'neurone/lib/api/matrix/client/v3/devices';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { useClient } from './useClient';
|
||||
|
||||
export const useDeviceListChange = (
|
||||
onChange: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated]
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
useEffect(() => {
|
||||
mx.on(CryptoEvent.DevicesUpdated, onChange);
|
||||
return () => {
|
||||
mx.removeListener(CryptoEvent.DevicesUpdated, onChange);
|
||||
};
|
||||
}, [mx, onChange]);
|
||||
export const useDeviceListChange = (onChange: (users: string[]) => void) => {
|
||||
const ct = useClient();
|
||||
|
||||
useEffect(() => ct.machine.events.onDevicesUpdated.subscribe(onChange), [ct, onChange]);
|
||||
};
|
||||
|
||||
const DEVICES_QUERY_KEY = ['devices'];
|
||||
|
||||
export function useDeviceList(): [undefined | IMyDevice[], () => Promise<void>] {
|
||||
const mx = useMatrixClient();
|
||||
export function useDeviceList(): [undefined | Device[], () => Promise<void>] {
|
||||
const ct = useClient();
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
const data = await mx.getDevices();
|
||||
return data.devices ?? [];
|
||||
}, [mx]);
|
||||
const resp = await ct.requestAuthJSON(getDevices({}));
|
||||
const [err, result] = await parseMatrixResp<GetDevicesOK>(resp);
|
||||
|
||||
if (err) console.error('Error loading devices!', err);
|
||||
|
||||
return result?.devices ?? [];
|
||||
}, [ct]);
|
||||
|
||||
const { data: deviceList, refetch } = useQuery({
|
||||
queryKey: DEVICES_QUERY_KEY,
|
||||
|
|
@ -41,29 +40,30 @@ export function useDeviceList(): [undefined | IMyDevice[], () => Promise<void>]
|
|||
useDeviceListChange(
|
||||
useCallback(
|
||||
(users) => {
|
||||
const userId = mx.getUserId();
|
||||
const { userId } = ct.session;
|
||||
if (userId && users.includes(userId)) {
|
||||
refreshDeviceList();
|
||||
}
|
||||
},
|
||||
[mx, refreshDeviceList]
|
||||
[ct, refreshDeviceList]
|
||||
)
|
||||
);
|
||||
|
||||
return [deviceList ?? undefined, refreshDeviceList];
|
||||
}
|
||||
|
||||
export const useDeviceIds = (devices: IMyDevice[] | undefined): string[] => {
|
||||
export const useDeviceIds = (devices: Device[] | undefined): string[] => {
|
||||
const devicesId = useMemo(() => devices?.map((device) => device.device_id) ?? [], [devices]);
|
||||
|
||||
return devicesId;
|
||||
};
|
||||
|
||||
export const useSplitCurrentDevice = (
|
||||
devices: IMyDevice[] | undefined
|
||||
): [IMyDevice | undefined, IMyDevice[] | undefined] => {
|
||||
const mx = useMatrixClient();
|
||||
const currentDeviceId = mx.getDeviceId();
|
||||
devices: Device[] | undefined
|
||||
): [Device | undefined, Device[] | undefined] => {
|
||||
const ct = useClient();
|
||||
const currentDeviceId = ct.session.deviceId;
|
||||
|
||||
const currentDevice = useMemo(
|
||||
() => devices?.find((d) => d.device_id === currentDeviceId),
|
||||
[devices, currentDeviceId]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { verifiedDevice } from '../utils/matrix-crypto';
|
||||
import { useAlive } from './useAlive';
|
||||
import { fulfilledPromiseSettledResult } from '../utils/common';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useDeviceListChange } from './useDeviceList';
|
||||
import { useClient } from './useClient';
|
||||
|
||||
export enum VerificationStatus {
|
||||
Unknown,
|
||||
|
|
@ -13,30 +11,32 @@ export enum VerificationStatus {
|
|||
Unsupported,
|
||||
}
|
||||
|
||||
export const useDeviceVerificationDetect = (
|
||||
crypto: CryptoApi | undefined,
|
||||
export const useDeviceVerificationStatus = (
|
||||
userId: string,
|
||||
deviceId: string | undefined,
|
||||
callback: (status: VerificationStatus) => void
|
||||
): void => {
|
||||
const mx = useMatrixClient();
|
||||
deviceId: string | undefined
|
||||
): VerificationStatus => {
|
||||
const [verificationStatus, setVerificationStatus] = useState(VerificationStatus.Unknown);
|
||||
const ct = useClient();
|
||||
|
||||
const updateStatus = useCallback(async () => {
|
||||
if (crypto && deviceId) {
|
||||
const data = await verifiedDevice(crypto, userId, deviceId);
|
||||
if (data === null) {
|
||||
callback(VerificationStatus.Unsupported);
|
||||
const device = await ct.machine.userTracker.getDevice(userId, deviceId);
|
||||
if (!device) {
|
||||
setVerificationStatus(VerificationStatus.Unsupported);
|
||||
return;
|
||||
}
|
||||
callback(data ? VerificationStatus.Verified : VerificationStatus.Unverified);
|
||||
|
||||
setVerificationStatus(
|
||||
device.crossSigningTrusted ? VerificationStatus.Verified : VerificationStatus.Unverified
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(VerificationStatus.Unknown);
|
||||
}, [crypto, deviceId, userId, callback]);
|
||||
setVerificationStatus(VerificationStatus.Unknown);
|
||||
}, [ct, deviceId, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
updateStatus();
|
||||
}, [mx, updateStatus, userId]);
|
||||
}, [ct, userId, updateStatus]);
|
||||
|
||||
useDeviceListChange(
|
||||
useCallback(
|
||||
|
|
@ -48,36 +48,25 @@ export const useDeviceVerificationDetect = (
|
|||
[userId, updateStatus]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeviceVerificationStatus = (
|
||||
crypto: CryptoApi | undefined,
|
||||
userId: string,
|
||||
deviceId: string | undefined
|
||||
): VerificationStatus => {
|
||||
const [verificationStatus, setVerificationStatus] = useState(VerificationStatus.Unknown);
|
||||
|
||||
useDeviceVerificationDetect(crypto, userId, deviceId, setVerificationStatus);
|
||||
|
||||
return verificationStatus;
|
||||
};
|
||||
|
||||
export const useUnverifiedDeviceCount = (
|
||||
crypto: CryptoApi | undefined,
|
||||
userId: string,
|
||||
devices: string[]
|
||||
): number | undefined => {
|
||||
export const useUnverifiedDeviceCount = (userId: string, devices: string[]): number | undefined => {
|
||||
const ct = useClient();
|
||||
const [unverifiedCount, setUnverifiedCount] = useState<number>();
|
||||
const alive = useAlive();
|
||||
|
||||
const updateCount = useCallback(async () => {
|
||||
let count = 0;
|
||||
if (crypto) {
|
||||
const promises = devices.map((deviceId) => verifiedDevice(crypto, userId, deviceId));
|
||||
const promises = devices.map((deviceId) =>
|
||||
ct.machine.userTracker.getDevice(userId, deviceId)
|
||||
);
|
||||
const result = await Promise.allSettled(promises);
|
||||
const settledResult = fulfilledPromiseSettledResult(result);
|
||||
settledResult.forEach((status) => {
|
||||
if (status === false) {
|
||||
settledResult.forEach((device) => {
|
||||
if (device && !device.verified) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
|
|
@ -85,7 +74,7 @@ export const useUnverifiedDeviceCount = (
|
|||
if (alive()) {
|
||||
setUnverifiedCount(count);
|
||||
}
|
||||
}, [crypto, userId, devices, alive]);
|
||||
}, [ct, userId, devices, alive]);
|
||||
|
||||
useDeviceListChange(
|
||||
useCallback(
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import {
|
||||
BackupTrustInfo,
|
||||
CryptoApi,
|
||||
CryptoEvent,
|
||||
CryptoEventHandlerMap,
|
||||
KeyBackupInfo,
|
||||
} from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAlive } from './useAlive';
|
||||
|
||||
export const useKeyBackupStatusChange = (
|
||||
onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupStatus]
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(CryptoEvent.KeyBackupStatus, onChange);
|
||||
return () => {
|
||||
mx.removeListener(CryptoEvent.KeyBackupStatus, onChange);
|
||||
};
|
||||
}, [mx, onChange]);
|
||||
};
|
||||
|
||||
export const useKeyBackupStatus = (crypto: CryptoApi): boolean => {
|
||||
const alive = useAlive();
|
||||
const [status, setStatus] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
crypto.getActiveSessionBackupVersion().then((v) => {
|
||||
if (alive()) {
|
||||
setStatus(typeof v === 'string');
|
||||
}
|
||||
});
|
||||
}, [crypto, alive]);
|
||||
|
||||
useKeyBackupStatusChange(setStatus);
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export const useKeyBackupSessionsRemainingChange = (
|
||||
onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupSessionsRemaining]
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(CryptoEvent.KeyBackupSessionsRemaining, onChange);
|
||||
return () => {
|
||||
mx.removeListener(CryptoEvent.KeyBackupSessionsRemaining, onChange);
|
||||
};
|
||||
}, [mx, onChange]);
|
||||
};
|
||||
|
||||
export const useKeyBackupFailedChange = (
|
||||
onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupFailed]
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(CryptoEvent.KeyBackupFailed, onChange);
|
||||
return () => {
|
||||
mx.removeListener(CryptoEvent.KeyBackupFailed, onChange);
|
||||
};
|
||||
}, [mx, onChange]);
|
||||
};
|
||||
|
||||
export const useKeyBackupDecryptionKeyCached = (
|
||||
onChange: CryptoEventHandlerMap[CryptoEvent.KeyBackupDecryptionKeyCached]
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(CryptoEvent.KeyBackupDecryptionKeyCached, onChange);
|
||||
return () => {
|
||||
mx.removeListener(CryptoEvent.KeyBackupDecryptionKeyCached, onChange);
|
||||
};
|
||||
}, [mx, onChange]);
|
||||
};
|
||||
|
||||
export const useKeyBackupSync = (): [number, string | undefined] => {
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [failure, setFailure] = useState<string>();
|
||||
|
||||
useKeyBackupSessionsRemainingChange(
|
||||
useCallback((count) => {
|
||||
setRemaining(count);
|
||||
setFailure(undefined);
|
||||
}, [])
|
||||
);
|
||||
|
||||
useKeyBackupFailedChange(
|
||||
useCallback((f) => {
|
||||
if (typeof f === 'string') {
|
||||
setFailure(f);
|
||||
setRemaining(0);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
return [remaining, failure];
|
||||
};
|
||||
|
||||
export const useKeyBackupInfo = (crypto: CryptoApi): KeyBackupInfo | undefined | null => {
|
||||
const alive = useAlive();
|
||||
const [info, setInfo] = useState<KeyBackupInfo | null>();
|
||||
|
||||
const fetchInfo = useCallback(() => {
|
||||
crypto.getKeyBackupInfo().then((i) => {
|
||||
if (alive()) {
|
||||
setInfo(i);
|
||||
}
|
||||
});
|
||||
}, [crypto, alive]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInfo();
|
||||
}, [fetchInfo]);
|
||||
|
||||
useKeyBackupStatusChange(fetchInfo);
|
||||
|
||||
useKeyBackupSessionsRemainingChange(
|
||||
useCallback(
|
||||
(remainingCount) => {
|
||||
if (remainingCount === 0) {
|
||||
fetchInfo();
|
||||
}
|
||||
},
|
||||
[fetchInfo]
|
||||
)
|
||||
);
|
||||
|
||||
return info;
|
||||
};
|
||||
|
||||
export const useKeyBackupTrust = (
|
||||
crypto: CryptoApi,
|
||||
backupInfo: KeyBackupInfo
|
||||
): BackupTrustInfo | undefined => {
|
||||
const alive = useAlive();
|
||||
const [trust, setTrust] = useState<BackupTrustInfo>();
|
||||
|
||||
const fetchTrust = useCallback(() => {
|
||||
crypto.isKeyBackupTrusted(backupInfo).then((t) => {
|
||||
if (alive()) {
|
||||
setTrust(t);
|
||||
}
|
||||
});
|
||||
}, [crypto, alive, backupInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTrust();
|
||||
}, [fetchTrust]);
|
||||
|
||||
useKeyBackupStatusChange(fetchTrust);
|
||||
|
||||
useKeyBackupDecryptionKeyCached(fetchTrust);
|
||||
|
||||
return trust;
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { backupRestoreProgressAtom } from '../state/backupRestore';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useKeyBackupDecryptionKeyCached } from './useKeyBackup';
|
||||
|
||||
export const useRestoreBackupOnVerification = () => {
|
||||
const setRestoreProgress = useSetAtom(backupRestoreProgressAtom);
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useKeyBackupDecryptionKeyCached(
|
||||
useCallback(() => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
crypto.restoreKeyBackup({
|
||||
progressCallback(progress) {
|
||||
setRestoreProgress(progress);
|
||||
},
|
||||
});
|
||||
}, [mx, setRestoreProgress])
|
||||
);
|
||||
};
|
||||
|
|
@ -1,22 +1,23 @@
|
|||
import {
|
||||
AccountDataEvent,
|
||||
SecretStorageDefaultKeyContent,
|
||||
SecretStorageKeyContent,
|
||||
} from '../../types/matrix/accountData';
|
||||
import { useAccountData } from './useAccountData';
|
||||
|
||||
export const getSecretStorageKeyEventType = (key: string): string => `m.secret_storage.key.${key}`;
|
||||
import { ObjectReader } from 'neurone/lib/utils';
|
||||
import { AesHmacSha2KeyDescription, KeyStorage } from 'neurone/lib/crypto/ssss';
|
||||
import { useAccountData } from './storage/useAccountData';
|
||||
|
||||
export const useSecretStorageDefaultKeyId = (): string | undefined => {
|
||||
const defaultKeyEvent = useAccountData(AccountDataEvent.SecretStorageDefaultKey);
|
||||
const defaultKeyId = defaultKeyEvent?.getContent<SecretStorageDefaultKeyContent>().key;
|
||||
const defaultKeyResult = useAccountData(KeyStorage.defaultKeyIdentifier());
|
||||
const defaultKeyEvent = defaultKeyResult.value;
|
||||
const defaultKeyId = ObjectReader.string(defaultKeyEvent?.content, 'key');
|
||||
|
||||
return defaultKeyId;
|
||||
};
|
||||
|
||||
export const useSecretStorageKeyContent = (keyId: string): SecretStorageKeyContent | undefined => {
|
||||
const keyEvent = useAccountData(getSecretStorageKeyEventType(keyId));
|
||||
const secretStorageKey = keyEvent?.getContent<SecretStorageKeyContent>();
|
||||
export const useSecretStorageKeyContent = (
|
||||
keyId: string
|
||||
): AesHmacSha2KeyDescription | undefined => {
|
||||
const keyResult = useAccountData(KeyStorage.keyIdentifier(keyId));
|
||||
const keyEvent = keyResult.value;
|
||||
const secretStorageKey = keyEvent?.content
|
||||
? KeyStorage.getKeyDescription(keyEvent.content)
|
||||
: undefined;
|
||||
|
||||
return secretStorageKey;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixError } from 'neurone/lib/matrix-request';
|
||||
import {
|
||||
AuthResponse,
|
||||
AuthType,
|
||||
|
|
@ -102,7 +102,7 @@ export const useUIAMatrixError = (
|
|||
error?: MatrixError
|
||||
): [undefined, undefined] | [AuthResponse, undefined] | [undefined, MatrixError] => {
|
||||
if (!error) return [undefined, undefined];
|
||||
if (error.httpStatus === 401) return [error.data as AuthResponse, undefined];
|
||||
if (error.status === 401) return [error.data as AuthResponse, undefined];
|
||||
|
||||
return [undefined, error];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
|
|||
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
|
||||
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
|
||||
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
|
||||
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
|
||||
import { RoomSettingsRenderer } from '../features/room-settings';
|
||||
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
|
||||
import { SpaceSettingsRenderer } from '../features/space-settings';
|
||||
|
|
@ -141,8 +140,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<CreateSpaceModalRenderer />
|
||||
<RoomSettingsRenderer />
|
||||
<SpaceSettingsRenderer />
|
||||
{/*<ReceiveSelfDeviceVerification />
|
||||
<AutoRestoreBackupOnVerification /> */}
|
||||
<ReceiveSelfDeviceVerification />
|
||||
</ClientRoomsNotificationPreferences>
|
||||
</ClientInitStorageAtom>
|
||||
</ClientRoot>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function SidebarNav() {
|
|||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SearchTab />
|
||||
{/* <UnverifiedTab /> */}
|
||||
<UnverifiedTab />
|
||||
<InboxTab />
|
||||
<SettingsTab />
|
||||
</SidebarStack>
|
||||
|
|
|
|||
|
|
@ -7,38 +7,29 @@ import {
|
|||
SidebarItemTooltip,
|
||||
} from '../../../components/sidebar';
|
||||
import { useDeviceIds, useDeviceList, useSplitCurrentDevice } from '../../../hooks/useDeviceList';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import * as css from './UnverifiedTab.css';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
useUnverifiedDeviceCount,
|
||||
VerificationStatus,
|
||||
} from '../../../hooks/useDeviceVerificationStatus';
|
||||
import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
|
||||
import { useCrossSigning } from '../../../hooks/useCrossSigning';
|
||||
import { Modal500 } from '../../../components/Modal500';
|
||||
import { Settings, SettingsPages } from '../../../features/settings';
|
||||
import { useClient } from '../../../hooks/useClient';
|
||||
|
||||
function UnverifiedIndicator() {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
|
||||
const crypto = mx.getCrypto();
|
||||
const [devices] = useDeviceList();
|
||||
|
||||
const [currentDevice, otherDevices] = useSplitCurrentDevice(devices);
|
||||
const [, otherDevices] = useSplitCurrentDevice(devices);
|
||||
|
||||
const verificationStatus = useDeviceVerificationStatus(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
currentDevice?.device_id
|
||||
);
|
||||
const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId);
|
||||
const unverified = verificationStatus === VerificationStatus.Unverified;
|
||||
|
||||
const otherDevicesId = useDeviceIds(otherDevices);
|
||||
const unverifiedDeviceCount = useUnverifiedDeviceCount(
|
||||
crypto,
|
||||
mx.getSafeUserId(),
|
||||
otherDevicesId
|
||||
);
|
||||
const unverifiedDeviceCount = useUnverifiedDeviceCount(ct.session.userId, otherDevicesId);
|
||||
|
||||
const [settings, setSettings] = useState(false);
|
||||
const closeSettings = () => setSettings(false);
|
||||
|
|
@ -86,9 +77,9 @@ function UnverifiedIndicator() {
|
|||
}
|
||||
|
||||
export function UnverifiedTab() {
|
||||
const crossSigningActive = useCrossSigningActive();
|
||||
const crossSigning = useCrossSigning();
|
||||
|
||||
if (!crossSigningActive) return null;
|
||||
if (!crossSigning) return null;
|
||||
|
||||
return <UnverifiedIndicator />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { atom } from 'jotai';
|
||||
import { ImportRoomKeyProgressData, ImportRoomKeyStage } from 'matrix-js-sdk/lib/crypto-api';
|
||||
|
||||
export enum BackupProgressStatus {
|
||||
Idle,
|
||||
Fetching,
|
||||
Loading,
|
||||
Done,
|
||||
}
|
||||
export type ProgressData = {
|
||||
downloaded: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
total: number;
|
||||
};
|
||||
export type IBackupProgress =
|
||||
| {
|
||||
status: BackupProgressStatus.Idle;
|
||||
}
|
||||
| {
|
||||
status: BackupProgressStatus.Fetching;
|
||||
}
|
||||
| {
|
||||
status: BackupProgressStatus.Loading;
|
||||
data: ProgressData;
|
||||
}
|
||||
| {
|
||||
status: BackupProgressStatus.Done;
|
||||
};
|
||||
|
||||
const baseBackupRestoreProgressAtom = atom<IBackupProgress>({
|
||||
status: BackupProgressStatus.Idle,
|
||||
});
|
||||
|
||||
export const backupRestoreProgressAtom = atom<
|
||||
IBackupProgress,
|
||||
[ImportRoomKeyProgressData],
|
||||
undefined
|
||||
>(
|
||||
(get) => get(baseBackupRestoreProgressAtom),
|
||||
(get, set, progress) => {
|
||||
if (progress.stage === ImportRoomKeyStage.Fetch) {
|
||||
set(baseBackupRestoreProgressAtom, {
|
||||
status: BackupProgressStatus.Fetching,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress.stage === ImportRoomKeyStage.LoadKeys) {
|
||||
const { total, successes, failures } = progress;
|
||||
|
||||
const downloaded = successes + failures;
|
||||
if (downloaded === total) {
|
||||
set(baseBackupRestoreProgressAtom, {
|
||||
status: BackupProgressStatus.Done,
|
||||
});
|
||||
return;
|
||||
}
|
||||
set(baseBackupRestoreProgressAtom, {
|
||||
status: BackupProgressStatus.Loading,
|
||||
data: {
|
||||
downloaded,
|
||||
successes,
|
||||
failures,
|
||||
total,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
|
||||
export const verifiedDevice = async (
|
||||
api: CryptoApi,
|
||||
userId: string,
|
||||
deviceId: string
|
||||
): Promise<boolean | null> => {
|
||||
const status = await api.getDeviceVerificationStatus(userId, deviceId);
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const verified = status.crossSigningVerified;
|
||||
return verified;
|
||||
};
|
||||
211
src/client/MegolmBackup.ts
Normal file
211
src/client/MegolmBackup.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { getKeys, GetKeysOK, PutKeysOK } from 'neurone/lib/api/matrix/client/v3/roomKeys';
|
||||
import { BackupVersionInfo } from 'neurone/lib/api/types';
|
||||
import { Machine } from 'neurone/lib/crypto/machine';
|
||||
import { KeyBackup, RoomKeysImportResult } from 'neurone/lib/crypto/machine/backup';
|
||||
import { MatrixError, MatrixRequest, parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { Base64 } from 'neurone/lib/utils';
|
||||
import { Disposable, Disposer } from 'neurone/lib/utils/disposable';
|
||||
import { ObservableValue } from 'neurone/lib/utils/observable';
|
||||
import { ProgressCallback, responseProgress } from './responseProgress';
|
||||
|
||||
export type ImportBackupIdle = {
|
||||
type: 'idle';
|
||||
};
|
||||
export type ImportBackupDownload = {
|
||||
type: 'download';
|
||||
receivedBytes: number;
|
||||
totalBytes?: number;
|
||||
};
|
||||
export type ImportBackupImport = {
|
||||
type: 'import';
|
||||
progress?: {
|
||||
imported: number;
|
||||
total: number;
|
||||
failures: number;
|
||||
};
|
||||
};
|
||||
export type ImportBackupDone = {
|
||||
type: 'done';
|
||||
result: RoomKeysImportResult;
|
||||
};
|
||||
export type ImportBackupError = {
|
||||
type: 'error';
|
||||
error: MatrixError;
|
||||
};
|
||||
|
||||
export type BackupRestoreState =
|
||||
| ImportBackupIdle
|
||||
| ImportBackupDownload
|
||||
| ImportBackupImport
|
||||
| ImportBackupDone
|
||||
| ImportBackupError;
|
||||
|
||||
export class MegolmBackup implements Disposable {
|
||||
private readonly disposer = new Disposer();
|
||||
|
||||
private readonly publishKey = crypto.randomUUID();
|
||||
|
||||
public readonly keyBackup: ObservableValue<KeyBackup | undefined>;
|
||||
|
||||
public readonly restoreState: ObservableValue<BackupRestoreState>;
|
||||
|
||||
public readonly backupInProgress: ObservableValue<boolean>;
|
||||
|
||||
constructor(
|
||||
private readonly machine: Machine,
|
||||
private readonly request: MatrixRequest,
|
||||
private readonly backupInfo: ObservableValue<BackupVersionInfo | undefined>
|
||||
) {
|
||||
this.keyBackup = new ObservableValue(undefined, this.publishKey);
|
||||
this.restoreState = new ObservableValue({ type: 'idle' }, this.publishKey);
|
||||
this.backupInProgress = new ObservableValue(false, this.publishKey);
|
||||
|
||||
this.handleBackupInfo(this.backupInfo.read);
|
||||
|
||||
this.disposer.collect(
|
||||
this.backupInfo.subscribe(this.handleBackupInfo.bind(this)),
|
||||
|
||||
this.machine.events.onReceiveSecret.subscribe(({ name, value }) => {
|
||||
if (name !== 'm.megolm_backup.v1') return;
|
||||
this.saveDecryptionKey(value);
|
||||
}),
|
||||
|
||||
this.machine.events.onRoomKeysUpdated.subscribe(() => {
|
||||
const keyBackup = this.keyBackup.read;
|
||||
if (keyBackup) {
|
||||
this.backup(keyBackup);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async handleBackupInfo(backupInfo?: BackupVersionInfo): Promise<void> {
|
||||
const prevBackup = this.keyBackup.read?.backupInfo;
|
||||
|
||||
const backupRemoved = !backupInfo && prevBackup !== undefined;
|
||||
if (backupRemoved) {
|
||||
await this.machine.backupManager.disableBackup();
|
||||
this.keyBackup.publish(this.publishKey, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!backupInfo) return;
|
||||
|
||||
const versionChanged = prevBackup && prevBackup.version !== backupInfo.version;
|
||||
if (versionChanged && this.keyBackup.read) {
|
||||
this.machine.backupManager.disableBackup();
|
||||
}
|
||||
|
||||
this.start(backupInfo);
|
||||
}
|
||||
|
||||
public async saveDecryptionKey(secret: string): Promise<void> {
|
||||
const backupInfo = this.backupInfo.read;
|
||||
if (!backupInfo) {
|
||||
throw new Error(
|
||||
'Cannot save the backup recovery key because no backup version is configured.'
|
||||
);
|
||||
}
|
||||
|
||||
await this.machine.backupManager.saveDecryptionKey(Base64.decode(secret), this.backupInfo.read);
|
||||
|
||||
const keyBackup = this.keyBackup.read ?? (await this.start(backupInfo));
|
||||
|
||||
this.restore(keyBackup);
|
||||
}
|
||||
|
||||
private async start(backupInfo: BackupVersionInfo): Promise<KeyBackup> {
|
||||
const trusted = await this.machine.backupManager.trustedBackup(backupInfo);
|
||||
if (!trusted) {
|
||||
throw new Error(
|
||||
'Cannot start key backup because the provided backup version is not trusted.'
|
||||
);
|
||||
}
|
||||
|
||||
const keyBackup = await this.machine.backupManager.enableBackup(backupInfo);
|
||||
this.keyBackup.publish(this.publishKey, keyBackup);
|
||||
|
||||
this.backup(keyBackup);
|
||||
|
||||
return keyBackup;
|
||||
}
|
||||
|
||||
private async backup(keyBackup: KeyBackup): Promise<void> {
|
||||
if (this.backupInProgress.read) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.backupInProgress.publish(this.publishKey, true);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const req = await keyBackup.backupRoomKeys();
|
||||
|
||||
// No more keys to back up.
|
||||
if (!req) break;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [err, result] = await parseMatrixResp<PutKeysOK>(req.response);
|
||||
|
||||
if (err) throw new Error(err.data.error ?? err.data.errcode);
|
||||
|
||||
console.debug(`Backed up ${result.count} room keys`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Backup keys request failed!', e);
|
||||
} finally {
|
||||
this.backupInProgress.publish(this.publishKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async restore(keyBackup: KeyBackup): Promise<void> {
|
||||
this.restoreState.publish(this.publishKey, {
|
||||
type: 'download',
|
||||
receivedBytes: 0,
|
||||
});
|
||||
const resp = await this.request(
|
||||
getKeys({ searchParams: { version: keyBackup.backupInfo.version } })
|
||||
);
|
||||
|
||||
const handleDownloadProgress: ProgressCallback = (receivedBytes, totalBytes) => {
|
||||
this.restoreState.publish(this.publishKey, {
|
||||
type: 'download',
|
||||
receivedBytes,
|
||||
totalBytes,
|
||||
});
|
||||
};
|
||||
await responseProgress(resp.clone(), handleDownloadProgress);
|
||||
|
||||
const [error, result] = await parseMatrixResp<GetKeysOK>(resp);
|
||||
if (error) {
|
||||
this.restoreState.publish(this.publishKey, {
|
||||
type: 'error',
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.restoreState.publish(this.publishKey, { type: 'import' });
|
||||
const handleImportProgress = (imported: bigint, total: bigint, failures: bigint) => {
|
||||
this.restoreState.publish(this.publishKey, {
|
||||
type: 'import',
|
||||
progress: {
|
||||
imported: Number(imported),
|
||||
total: Number(total),
|
||||
failures: Number(failures),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const importResult = await keyBackup.importBackup(result.rooms, handleImportProgress);
|
||||
this.restoreState.publish(this.publishKey, {
|
||||
type: 'done',
|
||||
result: importResult,
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposer.run();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
import { ComputedObservableValue, Observable, ObservableValue } from 'neurone/lib/utils/observable';
|
||||
import { MatrixRequest } from 'neurone/lib/matrix-request';
|
||||
import { getVersion, GetVersionOK } from 'neurone/lib/api/matrix/client/v3/roomKeys';
|
||||
import { MatrixRequest, parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { HistoryVisibility, Machine } from 'neurone/lib/crypto/machine';
|
||||
import { ObjectReader } from 'neurone/lib/utils';
|
||||
import { Disposer } from 'neurone/lib/utils/disposable';
|
||||
import { InboundVerification, OutboundVerification } from 'neurone/lib/crypto/machine/verification';
|
||||
import {
|
||||
AccountDataEvent,
|
||||
BackupVersionInfo,
|
||||
MessageEventWithoutRoomId,
|
||||
RoomSummary,
|
||||
StateEventWithoutRoomId,
|
||||
} from 'neurone/lib/api/types';
|
||||
import { AesHmacSha2EncryptedData, SecretStorage } from 'neurone/lib/crypto/ssss';
|
||||
import {
|
||||
InviteRoomStateObject,
|
||||
JoinRoomStateObject,
|
||||
|
|
@ -24,6 +28,7 @@ import { GetTxnId } from './txn';
|
|||
import { Timeline } from './room';
|
||||
import { AccountDataType } from '../types/matrix/accountData';
|
||||
import { DecryptionManager } from './decryption';
|
||||
import { MegolmBackup } from './MegolmBackup';
|
||||
|
||||
export type StateContext = {
|
||||
join: {
|
||||
|
|
@ -74,6 +79,17 @@ export type RoomsContext = {
|
|||
leave: ObservableValue<string[]>;
|
||||
};
|
||||
|
||||
export type CrossSigningSecrets = {
|
||||
master: Map<string, AesHmacSha2EncryptedData>;
|
||||
self: Map<string, AesHmacSha2EncryptedData>;
|
||||
user: Map<string, AesHmacSha2EncryptedData>;
|
||||
};
|
||||
|
||||
export type Secrets = {
|
||||
crossSigning: ObservableValue<CrossSigningSecrets | undefined>;
|
||||
megolmBackup: ObservableValue<Map<string, AesHmacSha2EncryptedData> | undefined>;
|
||||
};
|
||||
|
||||
export type ClientContext = {
|
||||
session: Session;
|
||||
storage: Storage;
|
||||
|
|
@ -105,6 +121,10 @@ export type ClientContext = {
|
|||
|
||||
pendingMemberRooms: ObservableValue<string[]>;
|
||||
verificationRequest: Observable<InboundVerification | OutboundVerification>;
|
||||
secrets: Secrets;
|
||||
backupInfo: ObservableValue<BackupVersionInfo | undefined>;
|
||||
megolmBackup: MegolmBackup;
|
||||
|
||||
sync: SyncContext;
|
||||
requestAuthJSON: MatrixRequest;
|
||||
getTxnId: GetTxnId;
|
||||
|
|
@ -895,3 +915,103 @@ export const createVerificationRequestObservable = () => {
|
|||
onVerificationRequest,
|
||||
};
|
||||
};
|
||||
|
||||
export const createSecretsObservable = async (
|
||||
storage: Storage,
|
||||
disposer: Disposer
|
||||
): Promise<Secrets> => {
|
||||
const masterObservable = await storage.accountData.observableRead.get(
|
||||
AccountDataType.CrossSigningMaster
|
||||
);
|
||||
const selfObservable = await storage.accountData.observableRead.get(
|
||||
AccountDataType.CrossSigningSelf
|
||||
);
|
||||
const userObservable = await storage.accountData.observableRead.get(
|
||||
AccountDataType.CrossSigningUser
|
||||
);
|
||||
|
||||
const backupObservable = await storage.accountData.observableRead.get(
|
||||
AccountDataType.MegolmBackupV1
|
||||
);
|
||||
|
||||
const getData = (event: AccountDataEvent) => {
|
||||
const kToD = new Map<string, AesHmacSha2EncryptedData>();
|
||||
|
||||
SecretStorage.getAllKeyId(event.content).forEach((keyId) => {
|
||||
const d = SecretStorage.getEncryptedData(keyId, event.content);
|
||||
if (!d) return;
|
||||
kToD.set(keyId, d);
|
||||
});
|
||||
|
||||
if (kToD.size === 0) return undefined;
|
||||
|
||||
return kToD;
|
||||
};
|
||||
|
||||
const crossSigning: ObservableValue<CrossSigningSecrets | undefined> =
|
||||
new ComputedObservableValue(() => {
|
||||
const masterEvent = masterObservable.read?.event;
|
||||
const selfEvent = selfObservable.read?.event;
|
||||
const userEvent = userObservable.read?.event;
|
||||
|
||||
const master = masterEvent && getData(masterEvent);
|
||||
const self = selfEvent && getData(selfEvent);
|
||||
const user = userEvent && getData(userEvent);
|
||||
|
||||
if (!master || !self || !user) return undefined;
|
||||
|
||||
return {
|
||||
master,
|
||||
self,
|
||||
user,
|
||||
};
|
||||
}, [masterObservable, selfObservable, userObservable]);
|
||||
|
||||
const megolmBackup: ObservableValue<Map<string, AesHmacSha2EncryptedData> | undefined> =
|
||||
new ComputedObservableValue(() => {
|
||||
const event = backupObservable.read?.event;
|
||||
if (!event) return undefined;
|
||||
|
||||
return getData(event);
|
||||
}, [backupObservable]);
|
||||
|
||||
disposer.collect(
|
||||
masterObservable,
|
||||
selfObservable,
|
||||
userObservable,
|
||||
backupObservable,
|
||||
crossSigning,
|
||||
megolmBackup
|
||||
);
|
||||
|
||||
return {
|
||||
crossSigning,
|
||||
megolmBackup,
|
||||
};
|
||||
};
|
||||
|
||||
export const createBackupInfoObservable = async (
|
||||
request: MatrixRequest,
|
||||
secrets: Secrets,
|
||||
disposer: Disposer
|
||||
): Promise<ObservableValue<BackupVersionInfo | undefined>> => {
|
||||
const publishKey = crypto.randomUUID();
|
||||
const backupInfo = new ObservableValue<BackupVersionInfo | undefined>(undefined, publishKey);
|
||||
|
||||
const update = async () => {
|
||||
const resp = await request(getVersion({}));
|
||||
const [, result] = await parseMatrixResp<GetVersionOK>(resp);
|
||||
backupInfo.publish(publishKey, result);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
disposer.collect(
|
||||
backupInfo,
|
||||
secrets.megolmBackup.subscribe(() => {
|
||||
update();
|
||||
})
|
||||
);
|
||||
|
||||
return backupInfo;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,16 +51,16 @@ export class DecryptionManager implements Disposable {
|
|||
const { content } = event;
|
||||
const sessionId = ObjectReader.string(content, 'session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error('Malformed event! Missing session_id');
|
||||
if (sessionId) {
|
||||
const existingEvent = eventIndex.get(roomId, sessionId, event.event_id);
|
||||
if (existingEvent) return existingEvent;
|
||||
}
|
||||
|
||||
const existingEvent = eventIndex.get(roomId, sessionId, event.event_id);
|
||||
if (existingEvent) return existingEvent;
|
||||
|
||||
const encryptedEvent = new EncryptedEvent(this.machine.roomEncryption, roomId, event);
|
||||
|
||||
eventIndex.store(roomId, sessionId, encryptedEvent);
|
||||
if (sessionId) {
|
||||
eventIndex.store(roomId, sessionId, encryptedEvent);
|
||||
}
|
||||
|
||||
return encryptedEvent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Disposer } from 'neurone/lib/utils/disposable';
|
|||
import { Session } from '../app/state/sessions';
|
||||
import {
|
||||
ClientContext,
|
||||
createSecretsObservable,
|
||||
createDirectRoomsObservable,
|
||||
createDirectRoomToUserObservable,
|
||||
createEncryptedRoomsObservable,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
createStateContext,
|
||||
createSyncTokenObservable,
|
||||
createVerificationRequestObservable,
|
||||
createBackupInfoObservable,
|
||||
} from './context';
|
||||
import { createStorage } from './storage';
|
||||
import { getAuthJSONRequest } from './request';
|
||||
|
|
@ -34,6 +36,7 @@ import { LogoutManager } from './LogoutManager';
|
|||
import { MachineManager } from './MachineManager';
|
||||
import { DecryptionManager } from './decryption';
|
||||
import { initializeCrypto } from './crypto';
|
||||
import { MegolmBackup } from './MegolmBackup';
|
||||
|
||||
export const initializeClient = async (session: Session): Promise<ClientContext> => {
|
||||
const disposer = new Disposer();
|
||||
|
|
@ -71,6 +74,9 @@ export const initializeClient = async (session: Session): Promise<ClientContext>
|
|||
const pendingMemberRooms = await createPendingMemberRoomObservable(storage, disposer);
|
||||
|
||||
const { verificationRequest, onVerificationRequest } = createVerificationRequestObservable();
|
||||
const secrets = await createSecretsObservable(storage, disposer);
|
||||
const backupInfo = await createBackupInfoObservable(requestAuthJSON, secrets, disposer);
|
||||
const megolmBackup = new MegolmBackup(machine, requestAuthJSON, backupInfo);
|
||||
|
||||
const getTxnId: GetTxnId = getTxnIdFunc();
|
||||
|
||||
|
|
@ -129,6 +135,9 @@ export const initializeClient = async (session: Session): Promise<ClientContext>
|
|||
|
||||
pendingMemberRooms,
|
||||
verificationRequest,
|
||||
secrets,
|
||||
backupInfo,
|
||||
megolmBackup,
|
||||
sync: {
|
||||
observable: syncObservable,
|
||||
controller: syncController,
|
||||
|
|
@ -148,6 +157,7 @@ export const initializeClient = async (session: Session): Promise<ClientContext>
|
|||
});
|
||||
|
||||
disposer.collect(
|
||||
megolmBackup,
|
||||
decryption,
|
||||
syncObservable,
|
||||
syncController,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ export enum AccountDataEvent {
|
|||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||
|
||||
CrossSigningMaster = 'm.cross_signing.master',
|
||||
CrossSigningSelf = 'm.cross_signing.self',
|
||||
CrossSigningUser = 'm.cross_signing.user',
|
||||
CrossSigningSelf = 'm.cross_signing.self_signing',
|
||||
CrossSigningUser = 'm.cross_signing.user_signing',
|
||||
MegolmBackupV1 = 'm.megolm_backup.v1',
|
||||
}
|
||||
export enum AccountDataType {
|
||||
|
|
@ -32,8 +32,8 @@ export enum AccountDataType {
|
|||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||
|
||||
CrossSigningMaster = 'm.cross_signing.master',
|
||||
CrossSigningSelf = 'm.cross_signing.self',
|
||||
CrossSigningUser = 'm.cross_signing.user',
|
||||
CrossSigningSelf = 'm.cross_signing.self_signing',
|
||||
CrossSigningUser = 'm.cross_signing.user_signing',
|
||||
MegolmBackupV1 = 'm.megolm_backup.v1',
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue