diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md new file mode 100644 index 000000000..686fb292f --- /dev/null +++ b/.changeset/devtool-rotate-sessions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index c8ffeb12d..470ca95c5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; -import { Box, Text, Scroll, Switch, Button } from 'folds'; +import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; +import { KnownMembership } from '$types/matrix-sdk'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -9,6 +10,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; @@ -25,6 +27,49 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + const [rotateState, rotateAllSessions] = useAsyncCallback< + { rotated: number; total: number }, + Error, + [] + >( + useCallback(async () => { + if ( + !window.confirm( + 'This will discard all current Megolm encryption sessions and start new ones. Continue?' + ) + ) { + throw new Error('Cancelled'); + } + + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Crypto module not available'); + + const encryptedRooms = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) + ); + + const results = await Promise.allSettled( + encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)) + ); + const rotated = results.filter((r) => r.status === 'fulfilled').length; + + // Proactively start session creation + key sharing with all devices + // (including bridge bots). fire-and-forget per room, but surface failures. + encryptedRooms.forEach((room) => { + Promise.resolve() + .then(() => crypto.prepareToEncrypt(room)) + .catch((error) => { + console.error('Failed to prepare room encryption', room.roomId, error); + }); + }); + + return { rotated, total: encryptedRooms.length }; + }, [mx]) + ); + const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { // TODO: remove cast once account data typing is unified. @@ -109,6 +154,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} {developerTools && (