move device verification setup and reset to new sdk also fix OAuth UIA
This commit is contained in:
parent
8133cac919
commit
613d6006ca
12 changed files with 443 additions and 264 deletions
|
|
@ -2,10 +2,10 @@ import React, { ReactNode } from 'react';
|
|||
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 { OAuthStage, PasswordStage, SSOStage } from './uia-stages';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
|
||||
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
|
||||
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth];
|
||||
|
||||
type ActionUIAProps = {
|
||||
authData: AuthResponse;
|
||||
|
|
@ -50,6 +50,9 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
submitAuthDict={action}
|
||||
/>
|
||||
)}
|
||||
{stageToComplete.type === AuthType.OAuth && (
|
||||
<OAuthStage stageData={stageToComplete} onCancel={onCancel} submitAuthDict={action} />
|
||||
)}
|
||||
</UIAFlowOverlay>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
|
||||
import React, {
|
||||
FormEventHandler,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
Header,
|
||||
|
|
@ -12,154 +19,236 @@ import {
|
|||
Chip,
|
||||
color,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
} from 'folds';
|
||||
import FileSaver from 'file-saver';
|
||||
import to from 'await-to-js';
|
||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||
import { BackupManager } from 'neurone/lib/crypto/machine/backup';
|
||||
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
|
||||
import {
|
||||
AesHmacSha2,
|
||||
AesHmacSha2KeyDescription,
|
||||
DefaultKey,
|
||||
KeyStorage,
|
||||
Passphrase,
|
||||
RecoveryKey,
|
||||
Secret,
|
||||
SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
|
||||
} from 'neurone/lib/crypto/ssss';
|
||||
import {
|
||||
MatrixRequestRetrier,
|
||||
MatrixUIAData,
|
||||
MatrixUIAHandler,
|
||||
randomString,
|
||||
sleep,
|
||||
} from 'neurone/lib/utils';
|
||||
import { postVersion, PostVersionBody } from 'neurone/lib/api/matrix/client/v3/roomKeys';
|
||||
import { parseMatrixResp } from 'neurone/lib/matrix-request';
|
||||
import { postDeviceSigningUpload } from 'neurone/lib/api/matrix/client/v3/keys';
|
||||
import { PasswordInput } from './password-input';
|
||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||
import { copyToClipboard } from '../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
import { UseStateProvider } from './UseStateProvider';
|
||||
import { useClient } from '../hooks/useClient';
|
||||
import { getMxIdLocalPart, getMxIdServer } from '../utils/matrix';
|
||||
import { AccountDataType } from '../../types/matrix/accountData';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
|
||||
type UIACallback<T> = (
|
||||
authDict: AuthDict | null
|
||||
) => Promise<[IAuthData, undefined] | [undefined, T]>;
|
||||
|
||||
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
|
||||
|
||||
type UIAAction<T> = {
|
||||
authData: IAuthData;
|
||||
callback: UIACallback<T>;
|
||||
cancelCallback: () => void;
|
||||
type SetupProgressChecklist = {
|
||||
bootstrapCrossSinging?: boolean;
|
||||
putMasterAccountData?: boolean;
|
||||
putSelfAccountData?: boolean;
|
||||
putUserAcountData?: boolean;
|
||||
verfiyOwnDevice?: boolean;
|
||||
postBackupVersion?: boolean;
|
||||
putBackupAccountData?: boolean;
|
||||
putRecoveryKeyAccountData?: boolean;
|
||||
putDefaultKeyAccountData?: boolean;
|
||||
};
|
||||
|
||||
function makeUIAAction<T>(
|
||||
authData: IAuthData,
|
||||
performAction: PerformAction<T>,
|
||||
resolve: (data: T) => void,
|
||||
reject: (error?: any) => void
|
||||
): UIAAction<T> {
|
||||
const action: UIAAction<T> = {
|
||||
authData,
|
||||
callback: async (authDict) => {
|
||||
const [error, data] = await to<T, MatrixError | Error>(performAction(authDict));
|
||||
|
||||
if (error instanceof MatrixError && error.httpStatus === 401) {
|
||||
return [error.data as IAuthData, undefined];
|
||||
}
|
||||
|
||||
if (error) {
|
||||
reject(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
resolve(data);
|
||||
return [undefined, data];
|
||||
},
|
||||
cancelCallback: reject,
|
||||
};
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
type SetupVerificationProps = {
|
||||
onComplete: (recoveryKey: string) => void;
|
||||
};
|
||||
function SetupVerification({ onComplete }: SetupVerificationProps) {
|
||||
const mx = useMatrixClient();
|
||||
const ct = useClient();
|
||||
const uia = useMemo(() => new MatrixUIAHandler(), []);
|
||||
const [uiaData, setUIAData] = useState<MatrixUIAData>();
|
||||
const [setupProgress, setSetupProgress] = useState<SetupProgressChecklist>();
|
||||
const alive = useAlive();
|
||||
|
||||
const [uiaAction, setUIAAction] = useState<UIAAction<void>>();
|
||||
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
|
||||
useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (authDict: AuthDict) => {
|
||||
if (!uiaAction) {
|
||||
throw new Error('Unexpected Error! UIA action is perform without data.');
|
||||
}
|
||||
if (alive()) {
|
||||
setNextAuthData(null);
|
||||
}
|
||||
const [authData] = await uiaAction.callback(authDict);
|
||||
|
||||
if (alive() && authData) {
|
||||
setNextAuthData(authData);
|
||||
}
|
||||
(authDict: object) => {
|
||||
setUIAData(undefined);
|
||||
uia.retry(authDict);
|
||||
},
|
||||
[uiaAction, alive]
|
||||
[uia]
|
||||
);
|
||||
|
||||
const resetUIA = useCallback(() => {
|
||||
if (!alive()) return;
|
||||
setUIAAction(undefined);
|
||||
setNextAuthData(undefined);
|
||||
}, [alive]);
|
||||
|
||||
const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
|
||||
(makeRequest) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
makeRequest(null)
|
||||
.then(() => {
|
||||
resolve();
|
||||
resetUIA();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof MatrixError && error.httpStatus === 401) {
|
||||
const authData = error.data as IAuthData;
|
||||
const action = makeUIAAction(
|
||||
authData,
|
||||
makeRequest as PerformAction<void>,
|
||||
resolve,
|
||||
(err) => {
|
||||
resetUIA();
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
if (alive()) {
|
||||
setUIAAction(action);
|
||||
} else {
|
||||
reject(new Error('Authentication failed! Failed to setup device verification.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
}),
|
||||
[alive, resetUIA]
|
||||
);
|
||||
const handleCancel = useCallback(() => {
|
||||
setUIAData(undefined);
|
||||
uia.clear();
|
||||
}, [uia]);
|
||||
|
||||
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
|
||||
useCallback(
|
||||
async (passphrase) => {
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
|
||||
setSetupProgress({});
|
||||
const retrier = new MatrixRequestRetrier(sleep);
|
||||
|
||||
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
|
||||
if (!recoveryKeyData.encodedPrivateKey) {
|
||||
throw new Error('Unexpected Error! Failed to create recovery key.');
|
||||
const defaultKeyId = randomString(16);
|
||||
|
||||
const passData = passphrase ? await Passphrase.create(passphrase) : undefined;
|
||||
const recoveryKey = passData?.key ?? AesHmacSha2.generateRawKey();
|
||||
|
||||
const uploadDeviceSigningKeys = async (bodyStr: string) => {
|
||||
const body = JSON.parse(bodyStr);
|
||||
const resp = await retrier.execute(() =>
|
||||
uia.execute((authData) =>
|
||||
ct.requestAuthJSON(postDeviceSigningUpload({ body: { ...body, auth: authData } }))
|
||||
)
|
||||
);
|
||||
const [err] = await parseMatrixResp(resp);
|
||||
if (err) throw new Error(err.data.error ?? err.data.errcode);
|
||||
};
|
||||
|
||||
await ct.machine.crossSigning.bootstrapCrossSigning(uploadDeviceSigningKeys);
|
||||
const crossSigning = await ct.machine.crossSigning.exportKeys();
|
||||
if (!crossSigning || !crossSigning.master || !crossSigning.self || !crossSigning.user) {
|
||||
throw new Error('Failed to reset cross signing. No cross signing keys available.');
|
||||
}
|
||||
clearSecretStorageKeys();
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, bootstrapCrossSinging: true }));
|
||||
|
||||
await crypto.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => recoveryKeyData,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
const masterEnc = await AesHmacSha2.encrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningMaster,
|
||||
crossSigning.master
|
||||
);
|
||||
const selfEnc = await AesHmacSha2.encrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningSelf,
|
||||
crossSigning.self
|
||||
);
|
||||
const userEnc = await AesHmacSha2.encrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.CrossSigningUser,
|
||||
crossSigning.user
|
||||
);
|
||||
|
||||
await crypto.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
const { userId } = ct.session;
|
||||
const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } };
|
||||
const selfSecret: Secret = { encrypted: { [defaultKeyId]: selfEnc } };
|
||||
const userSecret: Secret = { encrypted: { [defaultKeyId]: userEnc } };
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: AccountDataType.CrossSigningMaster },
|
||||
body: masterSecret,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putMasterAccountData: true }));
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: AccountDataType.CrossSigningSelf },
|
||||
body: selfSecret,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putSelfAccountData: true }));
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: AccountDataType.CrossSigningUser },
|
||||
body: userSecret,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putUserAcountData: true }));
|
||||
|
||||
await crypto.resetKeyBackup();
|
||||
const device = await ct.machine.userTracker.getDevice(
|
||||
ct.session.userId,
|
||||
ct.session.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 ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
|
||||
}
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, verfiyOwnDevice: true }));
|
||||
|
||||
onComplete(recoveryKeyData.encodedPrivateKey);
|
||||
const backupKey = BackupManager.createBackupKey();
|
||||
await ct.megolmBackup.cacheDecryptionKey(backupKey.privateKey);
|
||||
|
||||
const backupEnc = await AesHmacSha2.encrypt(
|
||||
recoveryKey,
|
||||
AccountDataType.MegolmBackupV1,
|
||||
backupKey.privateKey
|
||||
);
|
||||
|
||||
const backupAuthData = {
|
||||
public_key: backupKey.publicKey,
|
||||
};
|
||||
await ct.machine.sign(backupAuthData);
|
||||
|
||||
const backupVersion: PostVersionBody = {
|
||||
algorithm: backupKey.algorithm,
|
||||
auth_data: backupAuthData,
|
||||
};
|
||||
await retrier.execute(() => ct.requestAuthJSON(postVersion({ body: backupVersion })));
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, postBackupVersion: true }));
|
||||
|
||||
const backupSecret: Secret = { encrypted: { [defaultKeyId]: backupEnc } };
|
||||
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: AccountDataType.MegolmBackupV1 },
|
||||
body: backupSecret,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putBackupAccountData: true }));
|
||||
|
||||
const { iv, mac } = await AesHmacSha2.driveKeyMetaData(recoveryKey);
|
||||
|
||||
const keyDescription: AesHmacSha2KeyDescription = {
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
|
||||
passphrase: passData?.passphrase,
|
||||
iv,
|
||||
mac,
|
||||
};
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: KeyStorage.keyIdentifier(defaultKeyId) },
|
||||
body: keyDescription,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putRecoveryKeyAccountData: true }));
|
||||
const defaultKey: DefaultKey = {
|
||||
key: defaultKeyId,
|
||||
};
|
||||
await retrier.execute(() =>
|
||||
ct.requestAuthJSON(
|
||||
putAccountData({
|
||||
pathParams: { userId, type: KeyStorage.defaultKeyIdentifier() },
|
||||
body: defaultKey,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (alive()) setSetupProgress((v) => ({ ...v, putDefaultKeyAccountData: true }));
|
||||
|
||||
onComplete(RecoveryKey.encode(recoveryKey));
|
||||
},
|
||||
[mx, onComplete, authUploadDeviceSigningKeys]
|
||||
[ct, uia, onComplete, alive]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -179,43 +268,79 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
|
|||
setup(passphrase);
|
||||
};
|
||||
|
||||
const setupSteps: {
|
||||
key: keyof SetupProgressChecklist;
|
||||
label: string;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{ key: 'bootstrapCrossSinging', label: 'Setting up cross-signing' },
|
||||
{ key: 'putMasterAccountData', label: 'Securing your master signing key' },
|
||||
{ key: 'putSelfAccountData', label: 'Securing your self signing key' },
|
||||
{ key: 'putUserAcountData', label: 'Securing your user signing key' },
|
||||
{ key: 'verfiyOwnDevice', label: 'Verifying this device' },
|
||||
{ key: 'postBackupVersion', label: 'Creating your secure backup' },
|
||||
{ key: 'putBackupAccountData', label: 'Securing your backup key' },
|
||||
{ key: 'putRecoveryKeyAccountData', label: 'Saving your recovery key information' },
|
||||
{ key: 'putDefaultKeyAccountData', label: 'Setting up your recovery key' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const completedSteps = setupSteps.filter(({ key }) => setupProgress?.[key] === true).length;
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
|
||||
<Text size="T300">
|
||||
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
|
||||
devices. Additionally, setup a passphrase as a memorable alternative.
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Passphrase (Optional)</Text>
|
||||
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B400">Continue</Text>
|
||||
</Button>
|
||||
{setupProgress ? (
|
||||
<Box direction="Column" gap="400">
|
||||
<Text size="L400">Setup progress</Text>
|
||||
<Box style={{ paddingLeft: config.space.S200 }} direction="Column" gap="100">
|
||||
{setupSteps.map(({ key, label }) => (
|
||||
<Text size="T200" key={key}>
|
||||
{setupProgress[key] ? '✅' : '⏳'} {label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<ProgressBar variant="Secondary" size="300" min={0} value={completedSteps} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Text size="T300">
|
||||
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
|
||||
devices. Additionally, setup a passphrase as a memorable alternative.
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Passphrase (Optional)</Text>
|
||||
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B400">Continue</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{setupState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
|
||||
</Text>
|
||||
)}
|
||||
{nextAuthData !== null && uiaAction && (
|
||||
{uiaData && (
|
||||
<ActionUIAFlowsLoader
|
||||
authData={nextAuthData ?? uiaAction.authData}
|
||||
authData={uiaData}
|
||||
unsupported={() => (
|
||||
<Text size="T200">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Authentication steps to perform this action are not supported by client.
|
||||
</Text>
|
||||
)}
|
||||
>
|
||||
{(ongoingFlow) => (
|
||||
<ActionUIA
|
||||
authData={nextAuthData ?? uiaAction.authData}
|
||||
authData={uiaData}
|
||||
ongoingFlow={ongoingFlow}
|
||||
action={handleAction}
|
||||
onCancel={uiaAction.cancelCallback}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
</ActionUIAFlowsLoader>
|
||||
|
|
@ -228,6 +353,7 @@ type RecoveryKeyDisplayProps = {
|
|||
recoveryKey: string;
|
||||
};
|
||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||
const ct = useClient();
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
|
|
@ -235,10 +361,13 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const username = getMxIdLocalPart(ct.session.userId);
|
||||
const servername = getMxIdServer(ct.session.userId);
|
||||
|
||||
const blob = new Blob([recoveryKey], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'recovery-key.txt');
|
||||
FileSaver.saveAs(blob, `Recovery Key - ${username} (${servername}).txt`);
|
||||
};
|
||||
|
||||
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
|
||||
|
|
@ -270,12 +399,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button onClick={handleCopy}>
|
||||
<Text size="B400">Copy</Text>
|
||||
</Button>
|
||||
<Button onClick={handleDownload} fill="Soft">
|
||||
<Button onClick={handleDownload}>
|
||||
<Text size="B400">Download</Text>
|
||||
</Button>
|
||||
<Button onClick={handleCopy} fill="Soft">
|
||||
<Text size="B400">Copy</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import {
|
||||
AesHmacSha2,
|
||||
AesHmacSha2KeyDescription,
|
||||
Passphrase,
|
||||
PassphraseInfo,
|
||||
RecoveryKey,
|
||||
} from 'neurone/lib/crypto/ssss';
|
||||
import { PasswordInput } from './password-input';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
|
|
@ -129,7 +129,7 @@ export function SecretStorageRecoveryKey({
|
|||
>(
|
||||
useCallback(
|
||||
async (recoveryKey) => {
|
||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||
const decodedRecoveryKey = RecoveryKey.decode(recoveryKey);
|
||||
|
||||
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
|
||||
decodedRecoveryKey,
|
||||
|
|
|
|||
78
src/app/components/uia-stages/OAuthStage.tsx
Normal file
78
src/app/components/uia-stages/OAuthStage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ObjectReader } from 'neurone/lib/utils';
|
||||
import { StageComponentProps } from './types';
|
||||
|
||||
export function OAuthStage({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
|
||||
const { errorCode, error, session, info } = stageData;
|
||||
const url = ObjectReader.string(info, 'url');
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submitAuthDict({
|
||||
session,
|
||||
});
|
||||
}, [submitAuthDict, session]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">Reset encryption identity</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
<Box
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Text size="T200">
|
||||
To reset your end-to-end encryption cross-signing identity, you need to approve this
|
||||
request from your account dashboard.
|
||||
</Text>
|
||||
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">
|
||||
<b>1.</b> Open your account dashboard and approve the request.
|
||||
</Text>
|
||||
<Text size="T200">
|
||||
<b>2.</b> Return here after approving and Continue.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{errorCode && (
|
||||
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
|
||||
<Icon size="50" src={Icons.Warning} filled />
|
||||
<Text size="T200">
|
||||
<b>{`${errorCode}: ${error}`}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Open account dashboard
|
||||
</Text>
|
||||
</Button>
|
||||
<Button variant="Primary" onClick={handleSubmit}>
|
||||
<Text as="span" size="B400">
|
||||
Continue
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,3 +6,4 @@ export * from './ReCaptchaStage';
|
|||
export * from './RegistrationTokenStage';
|
||||
export * from './SSOStage';
|
||||
export * from './TermsStage';
|
||||
export * from './OAuthStage';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { LocalBackup } from './LocalBackup';
|
|||
import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile';
|
||||
import {
|
||||
DeviceVerificationOptions,
|
||||
EnableVerification,
|
||||
VerificationStatusBadge,
|
||||
VerifyCurrentDeviceTile,
|
||||
} from './Verification';
|
||||
|
|
@ -93,17 +92,15 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
title="Device Verification"
|
||||
description="To verify device identity and grant access to encrypted messages."
|
||||
after={
|
||||
crossSigning ? (
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Box gap="200" alignItems="Center">
|
||||
{crossSigning && (
|
||||
<VerificationStatusBadge
|
||||
verificationStatus={verificationStatus}
|
||||
otherUnverifiedCount={unverifiedDeviceCount}
|
||||
/>
|
||||
<DeviceVerificationOptions />
|
||||
</Box>
|
||||
) : (
|
||||
<EnableVerification />
|
||||
)
|
||||
)}
|
||||
<DeviceVerificationOptions enabled={!!crossSigning} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
|
|
|||
|
|
@ -31,9 +31,6 @@ import {
|
|||
DeviceVerificationSetup,
|
||||
} from '../../../components/DeviceVerificationSetup';
|
||||
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 = {
|
||||
|
|
@ -222,46 +219,15 @@ export function VerifyOtherDeviceTile({ deviceId }: VerifyOtherDeviceTileProps)
|
|||
);
|
||||
}
|
||||
|
||||
export function EnableVerification() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleCancel = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="300" radii="300" onClick={() => setOpen(true)}>
|
||||
<Text as="span" size="B300">
|
||||
Enable
|
||||
</Text>
|
||||
</Button>
|
||||
{open && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<DeviceVerificationSetup onCancel={handleCancel} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceVerificationOptions() {
|
||||
export function DeviceVerificationOptions({ enabled }: { enabled: boolean }) {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const authMetadata = useAuthMetadata();
|
||||
const accountManagementActions = useAccountManagementActions();
|
||||
|
||||
const [reset, setReset] = useState(false);
|
||||
const [setup, setSetup] = useState(false);
|
||||
|
||||
const handleCancelReset = useCallback(() => {
|
||||
const handleCancel = useCallback(() => {
|
||||
setReset(false);
|
||||
setSetup(false);
|
||||
}, []);
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||
|
|
@ -270,32 +236,31 @@ export function DeviceVerificationOptions() {
|
|||
|
||||
const handleReset = () => {
|
||||
setMenuCords(undefined);
|
||||
|
||||
if (authMetadata) {
|
||||
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
|
||||
window.open(
|
||||
withSearchParam(authUrl, {
|
||||
action: accountManagementActions.crossSigningReset,
|
||||
}),
|
||||
'_blank'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setReset(true);
|
||||
};
|
||||
const handleEnable = () => {
|
||||
setSetup(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-pressed={!!menuCords}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Icon size="100" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
{enabled ? (
|
||||
<IconButton
|
||||
aria-pressed={!!menuCords}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Icon size="100" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button size="300" radii="300" onClick={handleEnable}>
|
||||
<Text as="span" size="B300">
|
||||
Enable
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
|
|
@ -332,6 +297,21 @@ export function DeviceVerificationOptions() {
|
|||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{setup && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<DeviceVerificationSetup onCancel={handleCancel} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{reset && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
|
|
@ -342,7 +322,7 @@ export function DeviceVerificationOptions() {
|
|||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<DeviceVerificationReset onCancel={handleCancelReset} />
|
||||
<DeviceVerificationReset onCancel={handleCancel} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const useUIAFlow = (authData: AuthResponse, uiaFlow: UIAFlow): UIAFlowInt
|
|||
const nextStage = stages.find((stage) => !completed.includes(stage));
|
||||
if (!nextStage) return undefined;
|
||||
|
||||
const info = params[nextStage];
|
||||
const info = params[nextStage] as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
type: nextStage,
|
||||
|
|
@ -86,7 +86,7 @@ export const useUIAFlow = (authData: AuthResponse, uiaFlow: UIAFlow): UIAFlowInt
|
|||
(stageType: string): StageInfo | undefined => {
|
||||
if (!hasStage(stageType)) return undefined;
|
||||
|
||||
return params[stageType];
|
||||
return params[stageType] as Record<string, unknown>;
|
||||
},
|
||||
[hasStage, params]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,17 +8,20 @@ export enum AuthType {
|
|||
SsoUnstable = 'org.matrix.login.sso',
|
||||
Dummy = 'm.login.dummy',
|
||||
RegistrationToken = 'm.login.registration_token',
|
||||
OAuth = 'm.oauth',
|
||||
}
|
||||
|
||||
export type UIAFlow = {
|
||||
stages: Array<string>;
|
||||
};
|
||||
|
||||
export type UIAParams = Record<string, unknown>;
|
||||
|
||||
export type AuthResponse = {
|
||||
session?: string;
|
||||
completed?: string[];
|
||||
flows?: UIAFlow[];
|
||||
params?: Record<string, Record<string, any>>;
|
||||
params?: UIAParams;
|
||||
};
|
||||
|
||||
export const getSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] => {
|
||||
|
|
@ -34,7 +37,6 @@ export const getUIACompleted = (authData: AuthResponse): string[] => {
|
|||
return completed;
|
||||
};
|
||||
|
||||
export type UIAParams = Record<string, Record<string, unknown>>;
|
||||
export const getUIAParams = (authData: AuthResponse): UIAParams => {
|
||||
const params = authData.params ?? {};
|
||||
return params;
|
||||
|
|
@ -89,7 +91,12 @@ export const requiredStageInFlows = (uiaFlows: UIAFlow[], stage: string) =>
|
|||
|
||||
export const getLoginTermUrl = (params: UIAParams): string | undefined => {
|
||||
const terms = params[AuthType.Terms];
|
||||
if (terms && 'policies' in terms && typeof terms.policies === 'object') {
|
||||
if (
|
||||
typeof terms === 'object' &&
|
||||
terms &&
|
||||
'policies' in terms &&
|
||||
typeof terms.policies === 'object'
|
||||
) {
|
||||
if (terms.policies === null) return undefined;
|
||||
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
|
||||
if (terms.policies.privacy_policy === null) return undefined;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export class MegolmBackup implements Disposable {
|
|||
|
||||
public readonly backupInProgress: ObservableValue<boolean>;
|
||||
|
||||
private cachedDecryptionKey?: string;
|
||||
|
||||
constructor(
|
||||
private readonly machine: Machine,
|
||||
private readonly request: MatrixRequest,
|
||||
|
|
@ -93,10 +95,23 @@ export class MegolmBackup implements Disposable {
|
|||
|
||||
const versionChanged = prevBackup && prevBackup.version !== backupInfo.version;
|
||||
if (versionChanged && this.keyBackup.read) {
|
||||
this.machine.backupManager.disableBackup();
|
||||
await this.machine.backupManager.disableBackup();
|
||||
this.keyBackup.publish(this.publishKey, undefined);
|
||||
await this.start(backupInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
this.start(backupInfo);
|
||||
if (this.keyBackup.read) return;
|
||||
await this.start(backupInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* cache the decryption key when you are expecting a BackupVersionInfo update.
|
||||
*
|
||||
* @param secret backup private key
|
||||
*/
|
||||
public async cacheDecryptionKey(secret: string): Promise<void> {
|
||||
this.cachedDecryptionKey = secret;
|
||||
}
|
||||
|
||||
public async saveDecryptionKey(secret: string): Promise<void> {
|
||||
|
|
@ -127,6 +142,14 @@ export class MegolmBackup implements Disposable {
|
|||
|
||||
this.backup(keyBackup);
|
||||
|
||||
if (this.cachedDecryptionKey) {
|
||||
this.machine.backupManager.saveDecryptionKey(
|
||||
Base64.decode(this.cachedDecryptionKey),
|
||||
backupInfo
|
||||
);
|
||||
this.cachedDecryptionKey = undefined;
|
||||
}
|
||||
|
||||
return keyBackup;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
|
||||
|
||||
import { cryptoCallbacks } from './secretStorageKeys';
|
||||
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||
import { pushSessionToSW } from '../sw-session';
|
||||
|
||||
|
|
@ -28,7 +27,6 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||
cryptoStore: legacyCryptoStore,
|
||||
deviceId: session.deviceId,
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks: cryptoCallbacks as any,
|
||||
verificationMethods: ['m.sas.v1'],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
const secretStorageKeys = new Map();
|
||||
|
||||
export function storePrivateKey(keyId, privateKey) {
|
||||
if (privateKey instanceof Uint8Array === false) {
|
||||
throw new Error('Unable to store, privateKey is invalid.');
|
||||
}
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
function hasPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId) instanceof Uint8Array;
|
||||
}
|
||||
|
||||
function getPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId);
|
||||
}
|
||||
|
||||
export function clearSecretStorageKeys() {
|
||||
secretStorageKeys.clear();
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({ keys }) {
|
||||
const keyIds = Object.keys(keys);
|
||||
const keyId = keyIds.find(hasPrivateKey);
|
||||
if (!keyId) return undefined;
|
||||
const privateKey = getPrivateKey(keyId);
|
||||
return [keyId, privateKey];
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(keyId, keyInfo, privateKey) {
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
export const cryptoCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
};
|
||||
Loading…
Reference in a new issue