move device verification and megolm backup to new sdk

This commit is contained in:
Ajay Bura 2026-08-05 12:06:01 +05:30
commit 1447772209
30 changed files with 927 additions and 815 deletions

View file

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

View file

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

View file

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

View file

@ -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">

View file

@ -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}
/>
)}

View file

@ -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

View file

@ -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]
)
);

View file

@ -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

View file

@ -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>

View file

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

View file

@ -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
}
/>
)}

View file

@ -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">

View file

@ -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>

View file

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

View file

@ -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]

View file

@ -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(

View file

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

View file

@ -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])
);
};

View file

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

View file

@ -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];
};

View file

@ -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>

View file

@ -44,7 +44,7 @@ export function SidebarNav() {
<SidebarStackSeparator />
<SidebarStack>
<SearchTab />
{/* <UnverifiedTab /> */}
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</SidebarStack>

View file

@ -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 />;
}

View file

@ -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,
},
});
}
}
);

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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',
}