Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/components/BackupRestore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { BackupRestoreTile } from './BackupRestore';
const decodeRecoveryKey = vi.hoisted(() => vi.fn<(key: string) => Uint8Array>());
const emitter = new TypedEventEmitter<string, Record<string, (...args: never[]) => void>>();
const mockClient = Object.assign(emitter, {
secretStorage: { checkKey: vi.fn<() => Promise<boolean>>().mockResolvedValue(true) },
secretStorage: {
checkKey: vi.fn<() => Promise<boolean>>().mockResolvedValue(true),
get: vi.fn<(name: string) => Promise<string | undefined>>().mockResolvedValue('stored-key'),
},
getSafeUserId: () => '@me:example.org',
getDeviceId: () => 'DEVICE',
});
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/BackupRestore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
menuIcon,
} from '$components/icons/phosphor';
import { InfoCard } from './info-card';
import { restoreCrossSigningFromSecretStorage } from '$utils/matrix-crypto';

type BackupKeyRecoveryProps = {
crypto: CryptoApi;
Expand All @@ -70,7 +71,7 @@ function BackupKeyRecovery({
storePrivateKey(secretStorageKeyId, recoveryKey);

await cryptoBackend.processDeviceLists({ changed: [mx.getSafeUserId()] });
await cryptoBackend.bootstrapCrossSigning({});
await restoreCrossSigningFromSecretStorage(mx, cryptoBackend);
await cryptoBackend.bootstrapSecretStorage({});

// Emits KeyBackupDecryptionKeyCached, which drives the restore.
Expand Down
23 changes: 17 additions & 6 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,12 +363,23 @@ export function ReceiveSelfDeviceVerification() {
const crypto = mx.getCrypto();
if (!crypto?.getVerificationRequestsToDeviceInProgress) return undefined;

const pending = crypto
.getVerificationRequestsToDeviceInProgress(mx.getSafeUserId())
.find(
(candidate) => candidate.isSelfVerification && !candidate.initiatedByMe && candidate.pending
);
if (pending) setRequest(pending);
// The OlmMachine can be freed between the clientRunning check and this call.
try {
const pending = crypto
.getVerificationRequestsToDeviceInProgress(mx.getSafeUserId())
.find(
(candidate) =>
candidate.isSelfVerification && !candidate.initiatedByMe && candidate.pending
);
if (pending) setRequest(pending);
} catch (error) {
Sentry.addBreadcrumb({
category: 'crypto',
message: 'Could not read in-progress verification requests',
level: 'warning',
data: { error: error instanceof Error ? error.message : String(error) },
});
}
return undefined;
}, [mx]);

Expand Down
72 changes: 72 additions & 0 deletions src/app/components/DeviceVerificationSetup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CryptoApi } from '$types/matrix-sdk';
import { DeviceVerificationSetup } from './DeviceVerificationSetup';

const userHasCrossSigningKeys = vi.hoisted(() => vi.fn<() => Promise<boolean>>());
const createRecoveryKeyFromPassphrase = vi.hoisted(() =>
vi.fn<CryptoApi['createRecoveryKeyFromPassphrase']>()
);
const bootstrapSecretStorage = vi.hoisted(() => vi.fn<() => Promise<void>>());
const bootstrapCrossSigning = vi.hoisted(() => vi.fn<() => Promise<void>>());
const resetKeyBackup = vi.hoisted(() => vi.fn<() => Promise<void>>());

vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => ({
getSafeUserId: () => '@me:example.org',
getCrypto: () =>
({
userHasCrossSigningKeys,
createRecoveryKeyFromPassphrase,
bootstrapSecretStorage,
bootstrapCrossSigning,
resetKeyBackup,
}) as unknown as CryptoApi,
}),
}));

vi.mock('$client/secretStorageKeys', () => ({ clearSecretStorageKeys: vi.fn<() => void>() }));

const submitSetup = () => {
const form = document.querySelector('form') as HTMLFormElement;
fireEvent.submit(form);
};

describe('DeviceVerificationSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
createRecoveryKeyFromPassphrase.mockResolvedValue({
encodedPrivateKey: 'recovery-key',
privateKey: new Uint8Array([1, 2, 3]),
});
bootstrapSecretStorage.mockResolvedValue(undefined);
bootstrapCrossSigning.mockResolvedValue(undefined);
resetKeyBackup.mockResolvedValue(undefined);
});

it('refuses to set up again when the account already has cross-signing keys', async () => {
userHasCrossSigningKeys.mockResolvedValue(true);
render(<DeviceVerificationSetup onCancel={() => undefined} />);

submitSetup();

await waitFor(() =>
expect(screen.getByText(/already has device verification set up/)).toBeInTheDocument()
);
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
expect(bootstrapSecretStorage).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
expect(resetKeyBackup).not.toHaveBeenCalled();
});

it('sets up when the account has no cross-signing keys', async () => {
userHasCrossSigningKeys.mockResolvedValue(false);
render(<DeviceVerificationSetup onCancel={() => undefined} />);

submitSetup();

await waitFor(() => expect(bootstrapCrossSigning).toHaveBeenCalled());
expect(resetKeyBackup).toHaveBeenCalled();
expect(userHasCrossSigningKeys).toHaveBeenCalledWith('@me:example.org', true);
});
});
6 changes: 6 additions & 0 deletions src/app/components/DeviceVerificationSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ function SetupVerification({ onComplete, reset }: Readonly<SetupVerificationProp
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');

if (!reset && (await crypto.userHasCrossSigningKeys(mx.getSafeUserId(), true))) {
throw new Error(
'This account already has device verification set up. Verify with your recovery key or another device instead.'
);
}

const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.');
Expand Down
17 changes: 16 additions & 1 deletion src/app/components/ManualVerification.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ManualVerificationTile } from './ManualVerification';

const decodeRecoveryKey = vi.hoisted(() => vi.fn<(key: string) => Uint8Array>());
const checkKey = vi.hoisted(() => vi.fn<() => Promise<boolean>>());
const getSecret = vi.hoisted(() => vi.fn<(name: string) => Promise<string | undefined>>());
const storePrivateKey = vi.hoisted(() => vi.fn<() => void>());
const processDeviceLists = vi.hoisted(() => vi.fn<() => Promise<void>>());
const bootstrapCrossSigning = vi.hoisted(() => vi.fn<() => Promise<void>>());
Expand All @@ -19,7 +20,7 @@ vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => ({
getSafeUserId: () => '@me:example.org',
getDeviceId: () => 'DEVICE',
secretStorage: { checkKey },
secretStorage: { checkKey, get: getSecret },
getCrypto: () =>
({
processDeviceLists,
Expand Down Expand Up @@ -54,6 +55,7 @@ describe('ManualVerificationTile', () => {
vi.clearAllMocks();
decodeRecoveryKey.mockReturnValue(recoveryKey);
checkKey.mockResolvedValue(true);
getSecret.mockResolvedValue('stored-key');
processDeviceLists.mockResolvedValue(undefined);
bootstrapCrossSigning.mockResolvedValue(undefined);
bootstrapSecretStorage.mockResolvedValue(undefined);
Expand Down Expand Up @@ -84,4 +86,17 @@ describe('ManualVerificationTile', () => {
await waitFor(() => expect(screen.getByText('Device verified!')).toBeInTheDocument());
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['device-verification'] });
});

it('does not bootstrap when the cross-signing keys are missing from secret storage', async () => {
getSecret.mockResolvedValue(undefined);
renderTile(new QueryClient());

submitRecoveryKey('valid-key');

await waitFor(() =>
expect(screen.getByText(/Could not read your cross-signing keys/)).toBeInTheDocument()
);
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
expect(bootstrapSecretStorage).not.toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion src/app/components/ManualVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { storePrivateKey } from '$client/secretStorageKeys';
import { stopPropagation } from '$utils/keyboard';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useRefreshDeviceVerificationStatus } from '$hooks/useDeviceVerificationStatus';
import { restoreCrossSigningFromSecretStorage } from '$utils/matrix-crypto';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { AsyncError } from '$components/AsyncError';
import { SettingTile } from './setting-tile';
Expand Down Expand Up @@ -130,7 +131,7 @@ export function ManualVerificationTile({
storePrivateKey(secretStorageKeyId, recoveryKey);

await crypto.processDeviceLists({ changed: [mx.getSafeUserId()] });
await crypto.bootstrapCrossSigning({});
await restoreCrossSigningFromSecretStorage(mx, crypto);
await crypto.bootstrapSecretStorage({});

await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
Expand Down
10 changes: 7 additions & 3 deletions src/app/features/settings/devices/Devices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
VerificationStatus,
} from '$hooks/useDeviceVerificationStatus';
import { useSecretStorageDefaultKeyId, useSecretStorageKeyContent } from '$hooks/useSecretStorage';
import { useCrossSigningActive } from '$hooks/useCrossSigning';
import { CrossSigningStatus, useCrossSigningStatus } from '$hooks/useCrossSigning';
import { BackupRestoreTile } from '$components/BackupRestore';
import { LocalBackup } from './LocalBackup';
import { DeviceLogoutBtn, DeviceKeyDetails, DeviceTile, DeviceTilePlaceholder } from './DeviceTile';
Expand Down Expand Up @@ -41,7 +41,8 @@ type DevicesProps = {
export function Devices({ requestBack, requestClose }: DevicesProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto();
const crossSigningActive = useCrossSigningActive();
const crossSigningStatus = useCrossSigningStatus();
const crossSigningActive = crossSigningStatus === CrossSigningStatus.Active;
const [devices, refreshDeviceList] = useDeviceList();

useEffect(() => {
Expand Down Expand Up @@ -90,7 +91,10 @@ export function Devices({ requestBack, requestClose }: DevicesProps) {
description="To verify device identity and grant access to encrypted messages."
after={
<>
<EnableVerification visible={!crossSigningActive} />
<EnableVerification
visible={!crossSigningActive}
loading={crossSigningStatus === CrossSigningStatus.Unknown}
/>
{crossSigningActive && (
<Box gap="200" alignItems="Center">
<VerificationStatusBadge
Expand Down
11 changes: 9 additions & 2 deletions src/app/features/settings/devices/Verification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,16 +281,23 @@ export function VerifyOtherDeviceTile({ crypto, deviceId }: VerifyOtherDeviceTil

type EnableVerificationProps = {
visible: boolean;
loading?: boolean;
};
export function EnableVerification({ visible }: EnableVerificationProps) {
export function EnableVerification({ visible, loading }: EnableVerificationProps) {
const [open, setOpen] = useState(false);

const handleCancel = useCallback(() => setOpen(false), []);

return (
<>
{visible && (
<Button size="300" radii="300" onClick={() => setOpen(true)}>
<Button
size="300"
radii="300"
onClick={() => setOpen(true)}
disabled={loading}
before={loading && <Spinner size="100" variant="Primary" fill="Solid" />}
>
<Text as="span" size="B300">
Enable
</Text>
Expand Down
34 changes: 31 additions & 3 deletions src/app/hooks/useCrossSigning.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import type { SecretAccountData } from '$types/matrix/accountData';

import { useAccountData } from './useAccountData';
import { useMatrixClient } from './useMatrixClient';

export const useCrossSigningActive = (): boolean => {
export enum CrossSigningStatus {
Unknown,
Active,
Inactive,
}

// `getAccountData` reads the local store, so an unsynced account is indistinguishable from one
// that never set cross-signing up, and setting it up again resets it.
export const useCrossSigningStatus = (): CrossSigningStatus => {
const mx = useMatrixClient();
const masterEvent = useAccountData('m.cross_signing.master');
const content = masterEvent?.getContent<SecretAccountData>();
const storedLocally = !!masterEvent?.getContent<SecretAccountData>();

const { data } = useQuery({
queryKey: ['cross-signing-keys', mx.getSafeUserId()],
queryFn: async () => {
const crypto = mx.getCrypto();
if (!crypto) return null;
// An untracked own user reports false unless the key list is downloaded.
return crypto.userHasCrossSigningKeys(mx.getSafeUserId(), true);
},
enabled: !storedLocally,
staleTime: 60000,
});

return !!content;
if (storedLocally) return CrossSigningStatus.Active;
if (typeof data !== 'boolean') return CrossSigningStatus.Unknown;
return data ? CrossSigningStatus.Active : CrossSigningStatus.Inactive;
};

export const useCrossSigningActive = (): boolean =>
useCrossSigningStatus() === CrossSigningStatus.Active;
31 changes: 30 additions & 1 deletion src/app/utils/matrix-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CryptoApi } from '$types/matrix-sdk';
import type { CryptoApi, MatrixClient } from '$types/matrix-sdk';

export const verifiedDevice = async (
api: CryptoApi,
Expand All @@ -11,3 +11,32 @@ export const verifiedDevice = async (

return status.crossSigningVerified;
};

const CROSS_SIGNING_SECRETS = [
'm.cross_signing.master',
'm.cross_signing.self_signing',
'm.cross_signing.user_signing',
];

// `bootstrapCrossSigning` mints a new identity and writes it to 4S when it cannot read the keys,
// and account data missing from the local store reads as "no keys".
export const restoreCrossSigningFromSecretStorage = async (
mx: MatrixClient,
crypto: CryptoApi
): Promise<void> => {
const storedKeys = await Promise.all(
CROSS_SIGNING_SECRETS.map((secretName) => mx.secretStorage.get(secretName))
);

if (storedKeys.some((key) => !key)) {
throw new Error(
'Could not read your cross-signing keys from secret storage. Wait for the sync to finish and try again.'
);
}

await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async () => {
throw new Error('Refusing to replace your cross-signing identity to verify this device.');
},
});
};
3 changes: 3 additions & 0 deletions src/client/presenceSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export class PresenceSyncManager {
{ abortSignal: signal }
);

// Emitting after teardown drives SDK listeners into a crypto store that is closing.
if (signal.aborted || this.disposed || !this.mx.clientRunning) return;

this.processPresence(response);
this.syncToken = response.next_batch;

Expand Down
Loading