Skip to content
Closed
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: 5 additions & 0 deletions .changeset/devtool-rotate-sessions.md
Original file line number Diff line number Diff line change
@@ -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
98 changes: 97 additions & 1 deletion src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +10,7 @@
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';
Expand All @@ -25,6 +27,49 @@
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

const [rotateState, rotateAllSessions] = useAsyncCallback<
{ rotated: number; total: number },
Error,
[]
>(
useCallback(async () => {
if (
!window.confirm(

Check warning on line 37 in src/app/features/settings/developer-tools/DevelopTools.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 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);

Check warning on line 65 in src/app/features/settings/developer-tools/DevelopTools.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
});
});

return { rotated, total: encryptedRooms.length };
}, [mx])
);

const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
Expand Down Expand Up @@ -109,6 +154,57 @@
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Encryption</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
focusId="rotate-encryption-sessions"
title="Rotate Encryption Sessions"
description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys."
after={
<Button
onClick={rotateAllSessions}
Comment on lines +166 to +172
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={rotateState.status === AsyncStatus.Loading}
before={
rotateState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Secondary" />
)
}
>
<Text size="B300">
{rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
</Text>
</Button>
}
>
{rotateState.status === AsyncStatus.Success && (
<Text size="T200" style={{ color: color.Success.Main }}>
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.
</Text>
)}
{rotateState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{rotateState.error.message}
</Text>
)}
</SettingTile>
</SequenceCard>
</Box>
)}
{developerTools && (
<AccountData
expand={expand}
Expand Down
Loading