diff --git a/src/app/features/community-settings/AppCommunitySettingsScreen.tsx b/src/app/features/community-settings/AppCommunitySettingsScreen.tsx index d2423ca8..5641a736 100644 --- a/src/app/features/community-settings/AppCommunitySettingsScreen.tsx +++ b/src/app/features/community-settings/AppCommunitySettingsScreen.tsx @@ -30,6 +30,8 @@ import { getCommunityRole, getCommunityVisibility, getInviteBlockReason, + inviteToCommunity, + InviteToCommunityResult, parseInviteUserId, setCommunityVisibility, SetCommunityVisibilityResult, @@ -359,13 +361,8 @@ function InviteSection({ community, canInvite }: { community: Room; canInvite: b const [invited, setInvited] = useState(); const defaultServer = getMxIdServer(mx.getSafeUserId()); - const [inviteState, invite] = useAsyncCallback( - useCallback( - async (userId) => { - await mx.invite(community.roomId, userId); - }, - [mx, community.roomId] - ) + const [inviteState, invite] = useAsyncCallback( + useCallback((userId) => inviteToCommunity(mx, community, userId), [mx, community]) ); const inviting = inviteState.status === AsyncStatus.Loading; @@ -432,6 +429,15 @@ function InviteSection({ community, canInvite }: { community: Room; canInvite: b {invited && inviteState.status === AsyncStatus.Success && ( Invited {invited}. )} + {invited && + inviteState.status === AsyncStatus.Success && + inviteState.data.failedRoomIds.length > 0 && ( + + Couldn't invite them to{' '} + {pluralize(inviteState.data.failedRoomIds.length, 'room', 'rooms')}, so they may not + see older messages there. + + )} ); diff --git a/src/app/features/community-settings/communitySettings.test.ts b/src/app/features/community-settings/communitySettings.test.ts index bd17b7ae..2c6cd3ba 100644 --- a/src/app/features/community-settings/communitySettings.test.ts +++ b/src/app/features/community-settings/communitySettings.test.ts @@ -9,6 +9,7 @@ import { getSpaceVisibilityChanges, isCommunityAdmin, parseInviteUserId, + shouldInviteToChildRoom, shouldUpdateChildJoinRule, } from './communitySettings'; @@ -164,3 +165,44 @@ describe('getInviteBlockReason', () => { expect(getInviteBlockReason('leave')).toBeUndefined(); }); }); + +describe('shouldInviteToChildRoom', () => { + const base = { + encrypted: true, + historyVisibility: 'shared', + inviterMembership: 'join', + inviteeMembership: undefined, + canInvite: true, + }; + + it('invites to encrypted rooms with shared history the inviter is in', () => { + expect(shouldInviteToChildRoom(base)).toBe(true); + expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'world_readable' })).toBe(true); + }); + + it('treats unset history visibility as shared', () => { + expect(shouldInviteToChildRoom({ ...base, historyVisibility: undefined })).toBe(true); + }); + + it('skips rooms whose history new members cannot read', () => { + expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'joined' })).toBe(false); + expect(shouldInviteToChildRoom({ ...base, historyVisibility: 'invited' })).toBe(false); + }); + + it('skips unencrypted rooms, where there are no keys to share', () => { + expect(shouldInviteToChildRoom({ ...base, encrypted: false })).toBe(false); + }); + + it('skips rooms the inviter is not in or cannot invite to', () => { + expect(shouldInviteToChildRoom({ ...base, inviterMembership: 'leave' })).toBe(false); + expect(shouldInviteToChildRoom({ ...base, canInvite: false })).toBe(false); + }); + + it('only invites people who are not already in, invited to or banned from the room', () => { + expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'leave' })).toBe(true); + expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'knock' })).toBe(true); + expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'join' })).toBe(false); + expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'invite' })).toBe(false); + expect(shouldInviteToChildRoom({ ...base, inviteeMembership: 'ban' })).toBe(false); + }); +}); diff --git a/src/app/features/community-settings/communitySettings.ts b/src/app/features/community-settings/communitySettings.ts index 59115c24..9de6dc32 100644 --- a/src/app/features/community-settings/communitySettings.ts +++ b/src/app/features/community-settings/communitySettings.ts @@ -1,4 +1,10 @@ -import { JoinRule, MatrixClient, RestrictedAllowType, Room } from 'matrix-js-sdk'; +import { + HistoryVisibility, + JoinRule, + MatrixClient, + RestrictedAllowType, + Room, +} from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { Membership, StateEvent } from '../../../types/matrix/room'; import { @@ -218,3 +224,92 @@ export const getInviteBlockReason = (membership: string | undefined): string | u return undefined; } }; + +export type ChildRoomInviteCheck = { + encrypted: boolean; + historyVisibility: string | undefined; + inviterMembership: string | undefined; + inviteeMembership: string | undefined; + canInvite: boolean; +}; + +/** + * Whether inviting someone to a community should also invite them to one of + * its rooms. People joining a community reach its rooms through the space + * (restricted join), which never gives them the keys for encrypted messages + * sent before they joined: matrix-js-sdk only shares room history when + * inviting someone. So we invite them to the encrypted rooms whose history + * visibility lets new members read past messages, when we can. + */ +export const shouldInviteToChildRoom = ({ + encrypted, + historyVisibility, + inviterMembership, + inviteeMembership, + canInvite, +}: ChildRoomInviteCheck): boolean => { + if (!encrypted || !canInvite) return false; + if (inviterMembership !== Membership.Join) return false; + // Unset history visibility defaults to shared. + const visibility = historyVisibility ?? HistoryVisibility.Shared; + if (visibility !== HistoryVisibility.Shared && visibility !== HistoryVisibility.WorldReadable) { + return false; + } + return ( + inviteeMembership === undefined || + inviteeMembership === Membership.Leave || + inviteeMembership === Membership.Knock + ); +}; + +export type InviteToCommunityResult = { + /** Rooms in the community the user could not be invited to. */ + failedRoomIds: string[]; +}; + +/** + * Invites a user to a community, then to its encrypted rooms (see + * shouldInviteToChildRoom) so they get the room history's keys. The space + * invite must succeed; room failures are collected so one room doesn't hide + * that the community invite went through. + */ +export const inviteToCommunity = async ( + mx: MatrixClient, + space: Room, + userId: string +): Promise => { + await mx.invite(space.roomId, userId); + + const myUserId = mx.getSafeUserId(); + const childRooms = getSpaceChildren(space) + .map((roomId) => mx.getRoom(roomId)) + .filter((room): room is Room => !!room && !room.isSpaceRoom()) + .filter((room) => { + const powerLevels = getPowersLevelFromMatrixEvent( + getStateEvent(room, StateEvent.RoomPowerLevels) + ); + const permissions = getRoomPermissionsAPI( + getRoomCreatorsForRoomId(mx, room.roomId), + powerLevels + ); + return shouldInviteToChildRoom({ + encrypted: room.hasEncryptionStateEvent(), + historyVisibility: room.getHistoryVisibility(), + inviterMembership: room.getMyMembership(), + inviteeMembership: room.getMember(userId)?.membership, + canInvite: permissions.action('invite', myUserId), + }); + }); + + const results = await Promise.all( + childRooms.map(async (room): Promise => { + try { + await mx.invite(room.roomId, userId); + return undefined; + } catch { + return room.roomId; + } + }) + ); + return { failedRoomIds: results.filter((roomId): roomId is string => !!roomId) }; +}; diff --git a/src/app/pages/app-shell/auth/useAppRegisterComplete.ts b/src/app/pages/app-shell/auth/useAppRegisterComplete.ts index 47dd7223..fc31b295 100644 --- a/src/app/pages/app-shell/auth/useAppRegisterComplete.ts +++ b/src/app/pages/app-shell/auth/useAppRegisterComplete.ts @@ -9,6 +9,7 @@ import { getAppLoginPath, getAppPath, withSearchParam } from '../../pathUtils'; import { LoginPathSearchParams } from '../../paths'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; +import { markNewAccountForCrossSigning } from '../../../utils/newAccountCrossSigning'; /** * Forked from registerUtil.ts's useRegisterComplete: identical auto-session @@ -20,12 +21,15 @@ export const useAppRegisterComplete = (data?: CustomRegisterResponse) => { useEffect(() => { if (data) { - const { response, baseUrl } = data; + const { response, baseUrl, password } = data; const userId = response.user_id; const accessToken = response.access_token; const deviceId = response.device_id; + // Also when we're sent to login instead: the account is still new. + markNewAccountForCrossSigning(userId, password); + if (accessToken && deviceId) { setFallbackSession(accessToken, deviceId, userId, baseUrl); const afterLoginRedirectPath = getAfterLoginRedirectPath(); diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 86a38cb5..6e68b169 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -17,6 +17,7 @@ import { import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; +import { markNewAccountForCrossSigning } from '../../../utils/newAccountCrossSigning'; export enum RegisterError { UserTaken = 'UserTaken', @@ -33,6 +34,8 @@ export enum RegisterError { export type CustomRegisterResponse = { baseUrl: string; response: RegisterResponse; + /** Kept (in memory only) to authorize setting up cross-signing after registering. */ + password?: string; }; export type RegisterResult = [IAuthData, undefined] | [undefined, CustomRegisterResponse]; export const register = async ( @@ -103,6 +106,7 @@ export const register = async ( { baseUrl: mx.baseUrl, response: res, + password: requestData.password, }, ]; }; @@ -112,12 +116,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => { useEffect(() => { if (data) { - const { response, baseUrl } = data; + const { response, baseUrl, password } = data; const userId = response.user_id; const accessToken = response.access_token; const deviceId = response.device_id; + // Also when we're sent to login instead: the account is still new. + markNewAccountForCrossSigning(userId, password); + if (accessToken && deviceId) { setFallbackSession(accessToken, deviceId, userId, baseUrl); const afterLoginRedirectPath = getAfterLoginRedirectPath(); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ec9bc3a9..0dfb669e 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -29,6 +29,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useAppRoomNavigate } from '../../features/app-inbox/useAppRoomNavigate'; import { isPostsRoomName } from '../../features/app-feed/findPostsRoom'; import { getMessageNotificationBody } from '../../features/app-inbox/notificationBody'; +import { bootstrapNewAccountCrossSigning } from '../../utils/newAccountCrossSigning'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -273,6 +274,19 @@ function MessageNotifications() { ); } +function NewAccountCrossSigning() { + const mx = useMatrixClient(); + + useEffect(() => { + bootstrapNewAccountCrossSigning(mx).catch((e) => { + // eslint-disable-next-line no-console + console.error('Failed to set up cross-signing for the new account', e); + }); + }, [mx]); + + return null; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -285,6 +299,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/app/utils/newAccountCrossSigning.test.ts b/src/app/utils/newAccountCrossSigning.test.ts new file mode 100644 index 00000000..e740abfb --- /dev/null +++ b/src/app/utils/newAccountCrossSigning.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthDict, MatrixClient, MatrixError } from 'matrix-js-sdk'; +import { + bootstrapNewAccountCrossSigning, + clearPendingCrossSigning, + isCrossSigningPendingFor, + makeNewAccountSigningKeysAuth, + markNewAccountForCrossSigning, +} from './newAccountCrossSigning'; + +class MemoryStorage implements Storage { + private store = new Map(); + + get length() { + return this.store.size; + } + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.get(key) ?? null; + } + + key(index: number) { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +const USER_ID = '@alice:example.org'; + +const uiaError = () => + new MatrixError({ session: 'sess1', flows: [{ stages: ['m.login.password'] }] }, 401); + +beforeEach(() => { + vi.stubGlobal('localStorage', new MemoryStorage()); + clearPendingCrossSigning(); +}); + +describe('markNewAccountForCrossSigning', () => { + it('marks only the given account as pending until cleared', () => { + markNewAccountForCrossSigning(USER_ID); + expect(isCrossSigningPendingFor(USER_ID)).toBe(true); + expect(isCrossSigningPendingFor('@bob:example.org')).toBe(false); + + clearPendingCrossSigning(); + expect(isCrossSigningPendingFor(USER_ID)).toBe(false); + }); +}); + +describe('makeNewAccountSigningKeysAuth', () => { + it('uploads without auth when the server allows it', async () => { + const makeRequest = vi.fn<(auth: AuthDict | null) => Promise>(async () => undefined); + await makeNewAccountSigningKeysAuth(USER_ID, 'pw')(makeRequest); + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(null); + }); + + it('retries with the password when the server asks for auth', async () => { + const makeRequest = vi + .fn<(auth: AuthDict | null) => Promise>() + .mockRejectedValueOnce(uiaError()) + .mockResolvedValueOnce(undefined); + await makeNewAccountSigningKeysAuth(USER_ID, 'pw')(makeRequest); + expect(makeRequest).toHaveBeenLastCalledWith({ + type: 'm.login.password', + identifier: { type: 'm.id.user', user: USER_ID }, + password: 'pw', + session: 'sess1', + }); + }); + + it('gives up when auth is needed and there is no password', async () => { + const makeRequest = vi.fn(async () => { + throw uiaError(); + }); + await expect(makeNewAccountSigningKeysAuth(USER_ID)(makeRequest)).rejects.toThrow(); + expect(makeRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('bootstrapNewAccountCrossSigning', () => { + const makeClient = (hasKeys: boolean, bootstrap = vi.fn(async () => undefined)) => { + const crypto = { + userHasCrossSigningKeys: vi.fn(async () => hasKeys), + bootstrapCrossSigning: bootstrap, + }; + const mx = { + getSafeUserId: () => USER_ID, + getCrypto: () => crypto, + } as unknown as MatrixClient; + return { mx, crypto }; + }; + + it('does nothing for accounts not registered here', async () => { + const { mx, crypto } = makeClient(false); + await bootstrapNewAccountCrossSigning(mx); + expect(crypto.bootstrapCrossSigning).not.toHaveBeenCalled(); + }); + + it('sets up cross-signing for a new account, once', async () => { + markNewAccountForCrossSigning(USER_ID, 'pw'); + const { mx, crypto } = makeClient(false); + await bootstrapNewAccountCrossSigning(mx); + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledTimes(1); + expect(isCrossSigningPendingFor(USER_ID)).toBe(false); + + await bootstrapNewAccountCrossSigning(mx); + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledTimes(1); + }); + + it('never replaces an existing identity', async () => { + markNewAccountForCrossSigning(USER_ID); + const { mx, crypto } = makeClient(true); + await bootstrapNewAccountCrossSigning(mx); + expect(crypto.bootstrapCrossSigning).not.toHaveBeenCalled(); + expect(isCrossSigningPendingFor(USER_ID)).toBe(false); + }); + + it('stops trying when auth is rejected, but retries after other errors', async () => { + markNewAccountForCrossSigning(USER_ID); + const network = makeClient( + false, + vi.fn(async () => { + throw new Error('network'); + }) + ); + await expect(bootstrapNewAccountCrossSigning(network.mx)).rejects.toThrow('network'); + expect(isCrossSigningPendingFor(USER_ID)).toBe(true); + + const rejected = makeClient( + false, + vi.fn(async () => { + throw uiaError(); + }) + ); + await expect(bootstrapNewAccountCrossSigning(rejected.mx)).rejects.toThrow(); + expect(isCrossSigningPendingFor(USER_ID)).toBe(false); + }); +}); diff --git a/src/app/utils/newAccountCrossSigning.ts b/src/app/utils/newAccountCrossSigning.ts new file mode 100644 index 00000000..c5ceb9ab --- /dev/null +++ b/src/app/utils/newAccountCrossSigning.ts @@ -0,0 +1,102 @@ +import { + AuthDict, + AuthType, + IAuthData, + MatrixClient, + MatrixError, + UIAuthCallback, +} from 'matrix-js-sdk'; + +/** + * A brand-new account has no cross-signing identity, and until it has one: + * - matrix-js-sdk won't share room history with it on invite (MSC4268 key + * bundles are only sent to devices cross-signed by their owner), so it + * can't read messages sent before it joined; + * - clients that exclude insecure devices (e.g. Element) neither send it room + * keys nor accept keys from it, so its own messages show as undecryptable. + * + * So right after registering we set up cross-signing for the new device + * without asking. This only covers cross-signing - a recovery key (secret + * storage + key backup) still has to be set up by the user from Settings. + */ + +const PENDING_KEY = 'cinny_pending_cross_signing'; + +/** + * The password the account was just registered with, kept in memory only so + * we can answer the homeserver's auth prompt when uploading the signing keys + * (servers implementing MSC3967 don't prompt for the first upload). Never + * persisted - it's gone after a reload. + */ +let pendingPassword: { userId: string; password: string } | undefined; + +export const markNewAccountForCrossSigning = (userId: string, password?: string) => { + localStorage.setItem(PENDING_KEY, userId); + pendingPassword = password ? { userId, password } : undefined; +}; + +export const isCrossSigningPendingFor = (userId: string): boolean => + localStorage.getItem(PENDING_KEY) === userId; + +export const clearPendingCrossSigning = () => { + localStorage.removeItem(PENDING_KEY); + pendingPassword = undefined; +}; + +const getPendingPassword = (userId: string): string | undefined => + pendingPassword?.userId === userId ? pendingPassword.password : undefined; + +export const makePasswordAuth = (userId: string, password: string, session?: string): AuthDict => ({ + type: AuthType.Password, + identifier: { type: 'm.id.user', user: userId }, + password, + session, +}); + +/** + * Uploads the signing keys without auth first, and when the server asks for + * it (401) retries once with the account password, if we have it. + */ +export const makeNewAccountSigningKeysAuth = + (userId: string, password?: string): UIAuthCallback => + async (makeRequest) => { + try { + await makeRequest(null); + } catch (e) { + if (!password || !(e instanceof MatrixError) || e.httpStatus !== 401) throw e; + const { session } = (e.data ?? {}) as IAuthData; + await makeRequest(makePasswordAuth(userId, password, session)); + } + }; + +/** + * Sets up cross-signing for an account registered on this device, if it + * still needs it. Does nothing for other accounts, and never replaces an + * identity that already exists on the server. + */ +export const bootstrapNewAccountCrossSigning = async (mx: MatrixClient): Promise => { + const userId = mx.getSafeUserId(); + if (!isCrossSigningPendingFor(userId)) return; + const crypto = mx.getCrypto(); + if (!crypto) return; + + try { + if (!(await crypto.userHasCrossSigningKeys(userId, true))) { + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: makeNewAccountSigningKeysAuth( + userId, + getPendingPassword(userId) + ), + }); + } + clearPendingCrossSigning(); + } catch (e) { + // Auth rejected: we can't finish without the user, who can still set up + // verification from Settings. Anything else (e.g. network) is retried on + // the next load. + if (e instanceof MatrixError && (e.httpStatus === 401 || e.httpStatus === 403)) { + clearPendingCrossSigning(); + } + throw e; + } +};