diff --git a/apps/webapp/package.json b/apps/webapp/package.json index e04ab4c8de2..1ea46dc27cc 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -35,7 +35,7 @@ "@sindresorhus/is": "4.6.0", "@tanstack/react-table": "8.21.3", "@tanstack/react-virtual": "3.13.18", - "@wireapp/avs": "10.2.21", + "@wireapp/avs": "10.2.38", "@wireapp/avs-debugger": "0.0.7", "@wireapp/config": "workspace:^", "@wireapp/core": "workspace:^", diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json index ccb2bedbd8e..19b1a6855ce 100644 --- a/apps/webapp/src/i18n/en-US.json +++ b/apps/webapp/src/i18n/en-US.json @@ -1049,15 +1049,15 @@ "groupCreationPreferencesNonFederatingLeave": "Discard Group Creation", "groupCreationPreferencesNonFederatingMessage": "People from backends {backends} can’t join the same group conversation, as their backends can’t communicate with each other. To create the group, remove affected participants. [link]Learn more[/link]", "groupCreationPreferencesPlaceholder": "Group name", - "groupDetailsActionDelete": "Delete group", - "groupDetailsActionLeave": "Leave group", + "groupDetailsActionDelete": "Delete conversation", + "groupDetailsActionLeave": "Leave conversation", "groupParticipantActionBlock": "Block…", "groupParticipantActionCancelRequest": "Cancel request…", "groupParticipantActionDevices": "Devices", "groupParticipantActionDevicesGoBack": "Go back to device details", "groupParticipantActionIgnoreRequest": "Ignore request", "groupParticipantActionIncomingRequest": "Accept request", - "groupParticipantActionLeave": "Leave group…", + "groupParticipantActionLeave": "Leave conversation…", "groupParticipantActionOpenConversation": "Open conversation", "groupParticipantActionPending": "Pending", "groupParticipantActionRemove": "Remove from group…", @@ -1066,7 +1066,7 @@ "groupParticipantActionStartConversation": "Start conversation", "groupParticipantActionUnblock": "Unblock…", "groupSizeInfo": "Up to {count} people can join a group conversation.", - "groupsPopoverLeave": "Leave group", + "groupsPopoverLeave": "Leave conversation", "guestLinkDisabled": "Generating guest links is not allowed in your team.", "guestLinkDisabledByOtherTeam": "You can't generate a guest link in this conversation, as it has been created by someone from another team and this team is not allowed to use guest links.", "guestLinkPasswordModal.conversationPasswordProtected": "This conversation is password protected.", @@ -1171,6 +1171,19 @@ "layoutSidebarContent": "Connect, message, and share files with ease, protected by the industry's most secure end-to-end encryption", "layoutSidebarHeader": "Collaborate without Compromise", "layoutSidebarLink": "Learn more", + "leaveGroupAdminModalCancelAction": "Cancel", + "leaveGroupAdminModalClearContent": "Also clear the content", + "leaveGroupAdminModalClose": "Close window 'Leave {name}'", + "leaveGroupAdminModalDeleteAction": "Delete conversation", + "leaveGroupAdminModalLeaveAction": "Leave", + "leaveGroupAdminModalMessageNoEligibleFirstPart": "You're currently the only admin in this group with no other participant eligible to be promoted as admin.", + "leaveGroupAdminModalMessageNoEligibleSecondPart": "Add at least one additional admin, after that you can leave this group. If you no longer need the group or its content, consider deleting it instead.", + "leaveGroupAdminModalMessageWithEligibleFirstPart": "You're currently the only admin in this group. To prevent the group from being left without management after you leave, promote another participant to admin.", + "leaveGroupAdminModalMessageWithEligibleSecondPart": "If you no longer need the group or its content, consider deleting it instead.", + "leaveGroupAdminModalNewAdminLabel": "New admin", + "leaveGroupAdminModalPromoteAction": "Leave conversation", + "leaveGroupAdminModalSearchPlaceholder": "Enter a name", + "leaveGroupAdminModalTitle": "Leave {name}?", "legalHoldActivated": "This conversation is under legal hold", "legalHoldActivatedLearnMore": "Learn more", "legalHoldDeactivated": "Legal hold deactivated for this conversation", diff --git a/apps/webapp/src/script/E2EIdentity/E2EIdentityEnrollment.ts b/apps/webapp/src/script/E2EIdentity/E2EIdentityEnrollment.ts index f9515045999..b12d13d55a7 100644 --- a/apps/webapp/src/script/E2EIdentity/E2EIdentityEnrollment.ts +++ b/apps/webapp/src/script/E2EIdentity/E2EIdentityEnrollment.ts @@ -41,7 +41,7 @@ import { MLSStatuses, } from './E2EIdentityVerification'; import {getEnrollmentStore} from './Enrollment.store'; -import {getEnrollmentTimer, hasGracePeriodStartedForSelfClient} from './EnrollmentTimer'; +import {getEnrollmentTimer, getRemainingGracePeriodDelay, hasGracePeriodStartedForSelfClient} from './EnrollmentTimer'; import {getModalOptions, ModalType} from './Modals'; import {OIDCService} from './OIDCService'; import {OIDCServiceStore} from './OIDCService/OIDCServiceStorage'; @@ -230,7 +230,10 @@ export class E2EIHandler extends TypedEventEmitter { intervalDelay: TIME_IN_MILLIS.SECOND * 10, }); } - return firingDate - Date.now(); + return { + nextReminderDelay: firingDate - Date.now(), + remainingGracePeriodDelay: getRemainingGracePeriodDelay(identity, e2eActivatedAt, this.config.gracePeriodInMs), + }; } private async processEnrollmentUponExpiry(snoozable: boolean, onUserAction: () => void) { @@ -399,9 +402,9 @@ export class E2EIHandler extends TypedEventEmitter { resolve(); }, secondaryActionFn: async () => { - const delay = await this.startTimers(); - if (delay > 0) { - this.showSnoozeConfirmationModal(delay); + const {nextReminderDelay, remainingGracePeriodDelay} = await this.startTimers(); + if (nextReminderDelay > 0) { + this.showSnoozeConfirmationModal(remainingGracePeriodDelay); } resolve(); }, @@ -429,9 +432,9 @@ export class E2EIHandler extends TypedEventEmitter { }, secondaryActionFn: async () => { onUserAction?.(); - const delay = await this.startTimers(); - if (delay > 0) { - this.showSnoozeConfirmationModal(delay); + const {nextReminderDelay, remainingGracePeriodDelay} = await this.startTimers(); + if (nextReminderDelay > 0) { + this.showSnoozeConfirmationModal(remainingGracePeriodDelay); } resolve(); }, diff --git a/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.test.ts b/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.test.ts index 243fa4110d1..68bfa5456ed 100644 --- a/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.test.ts +++ b/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.test.ts @@ -20,9 +20,33 @@ import {TimeInMillis} from '@wireapp/commons/lib/util/TimeUtil'; import {CredentialType} from '@wireapp/core/lib/messagingProtocols/mls'; -import {getEnrollmentTimer, messageRetentionTime} from './EnrollmentTimer'; +import {getEnrollmentTimer, getRemainingGracePeriodDelay, messageRetentionTime} from './EnrollmentTimer'; -import {MLSStatuses} from '../E2EIdentityVerification'; +import {MLSStatuses, WireIdentity} from '../E2EIdentityVerification'; +import {createFakeWallClock} from 'src/script/clock/fakeWallClock'; + +const generateWireIdentity = ( + credentialType: CredentialType = CredentialType.X509, + status: MLSStatuses = MLSStatuses.NOT_ACTIVATED, +): WireIdentity => ({ + x509Identity: { + free: jest.fn(), + certificate: '', + displayName: 'John Doe', + domain: 'domain', + handle: 'johndoe', + notAfter: BigInt(0), + notBefore: BigInt(0), + serialNumber: '', + [Symbol.dispose]: () => {}, + }, + thumbprint: '', + credentialType, + status, + clientId: 'client-id', + deviceId: 'client-id', + qualifiedUserId: {id: 'user-id', domain: 'domain'}, +}); describe('e2ei delays', () => { const gracePeriod = 7 * TimeInMillis.DAY; @@ -99,4 +123,60 @@ describe('e2ei delays', () => { expect(isSnoozable).toBeTruthy(); expect(firingDate).toBe(gracePeriodStartingPoint); }); + + it.each([ + TimeInMillis.HOUR, + TimeInMillis.HOUR * 6, + TimeInMillis.HOUR * 12, + TimeInMillis.HOUR * 24, + TimeInMillis.WEEK, + ])('should keep full remaining grace period for first enrollment: %i ms', grace => { + const remainingDelay = getRemainingGracePeriodDelay(undefined, Date.now(), grace); + + expect(remainingDelay).toBe(grace); + }); + + it('should return a deterministic full grace-period delay when identity is undefined', () => { + const deterministicWallClock = createFakeWallClock({ + initialCurrentTimestampInMilliseconds: 1_700_000_000_000, + }); + const grace = TimeInMillis.HOUR * 12; + + const remainingDelay = getRemainingGracePeriodDelay( + undefined, + deterministicWallClock.currentTimestampInMilliseconds, + grace, + deterministicWallClock, + ); + + expect(remainingDelay).toBe(grace); + }); + + it('should return only the remaining grace-period delay when first enrollment started in the past', () => { + const deterministicWallClock = createFakeWallClock({ + initialCurrentTimestampInMilliseconds: 1_700_000_000_000, + }); + const grace = TimeInMillis.DAY * 7; + const e2eiActivatedAt = deterministicWallClock.currentTimestampInMilliseconds - TimeInMillis.DAY * 2; + + const remainingDelay = getRemainingGracePeriodDelay(undefined, e2eiActivatedAt, grace, deterministicWallClock); + + expect(remainingDelay).toBe(TimeInMillis.DAY * 5); + }); + + it('should treat NOT_ACTIVATED identity as first enrollment', () => { + const deterministicWallClock = createFakeWallClock({ + initialCurrentTimestampInMilliseconds: 1_700_000_000_000, + }); + const grace = TimeInMillis.HOUR * 6; + + const remainingDelay = getRemainingGracePeriodDelay( + generateWireIdentity(CredentialType.X509, MLSStatuses.NOT_ACTIVATED), + deterministicWallClock.currentTimestampInMilliseconds, + grace, + deterministicWallClock, + ); + + expect(remainingDelay).toBe(grace); + }); }); diff --git a/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.ts b/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.ts index b9981dbb945..16764906993 100644 --- a/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.ts +++ b/apps/webapp/src/script/E2EIdentity/EnrollmentTimer/EnrollmentTimer.ts @@ -17,10 +17,13 @@ * */ +import is from '@sindresorhus/is'; import {randomInt} from '@wireapp/commons/lib/util/RandomUtil'; import {TimeInMillis} from '@wireapp/commons/lib/util/TimeUtil'; import {CredentialType} from '@wireapp/core/lib/messagingProtocols/mls'; +import {WallClock, createWallClock} from 'src/script/clock/wallClock'; + import {MLSStatuses, WireIdentity} from '../E2EIdentityVerification'; const FIVE_MINUTES = TimeInMillis.MINUTE * 5; @@ -38,21 +41,25 @@ type GracePeriod = { /** end date of the grace period (unix timestamp) */ end: number; }; + +const wallClock = createWallClock(); /** * Will return a suitable snooze time based on the grace period * @param deadline - the full grace period length in milliseconds */ -function getNextTick({end, start}: GracePeriod): number { - if (Date.now() >= end) { +function getNextTick({end, start}: GracePeriod, activeWallClock: WallClock): number { + const now = activeWallClock.currentTimestampInMilliseconds; + + if (now >= end) { // If the grace period is over, we should force the user to enroll return 0; } - if (Date.now() < start) { + if (now < start) { // If we are not in the grace period yet, we start the timer when the grace period starts - return start - Date.now(); + return start - now; } - const validityPeriod = end - Date.now(); + const validityPeriod = end - now; if (validityPeriod <= FIFTEEN_MINUTES) { return Math.min(FIVE_MINUTES, validityPeriod); @@ -70,17 +77,25 @@ function getGracePeriod( identity: WireIdentity | undefined, e2eActivatedAt: number, teamGracePeriodDuration: number, + activeWallClock: WallClock, ): GracePeriod { - const isFirstEnrollment = identity?.credentialType === CredentialType.Basic; + const isFirstEnrollment = + is.undefined(identity) || + identity.status === MLSStatuses.NOT_ACTIVATED || + identity.credentialType === CredentialType.Basic; + if (isFirstEnrollment) { // For a new device, the deadline is the e2ei activate date + the grace period - return {end: e2eActivatedAt + teamGracePeriodDuration, start: Date.now()}; + return { + end: e2eActivatedAt + teamGracePeriodDuration, + start: activeWallClock.currentTimestampInMilliseconds, + }; } // To be sure the device does not expire, we want to keep a safe delay const safeDelay = randomInt(TimeInMillis.DAY) + messageRetentionTime; - const end = Number(identity?.x509Identity?.notAfter) * TimeInMillis.SECOND; + const end = Number(identity.x509Identity?.notAfter) * TimeInMillis.SECOND; const start = Math.max(end - safeDelay, end - teamGracePeriodDuration); return {end, start}; @@ -90,23 +105,35 @@ export function getEnrollmentTimer( identity: WireIdentity | undefined, e2eiActivatedAt: number, teamGracePeriodDuration: number, + activeWallClock: WallClock = wallClock, ) { if (identity?.status === MLSStatuses.EXPIRED) { - return {isSnoozable: false, firingDate: Date.now()}; + return {isSnoozable: false, firingDate: activeWallClock.currentTimestampInMilliseconds}; } - const deadline = getGracePeriod(identity, e2eiActivatedAt, teamGracePeriodDuration); - const nextTick = getNextTick(deadline); + const deadline = getGracePeriod(identity, e2eiActivatedAt, teamGracePeriodDuration, activeWallClock); + const nextTick = getNextTick(deadline, activeWallClock); // When logging in to a old device that doesn't have an identity yet, we trigger an enrollment timer - return {isSnoozable: nextTick > 0, firingDate: Date.now() + nextTick}; + return {isSnoozable: nextTick > 0, firingDate: activeWallClock.currentTimestampInMilliseconds + nextTick}; +} + +export function getRemainingGracePeriodDelay( + identity: WireIdentity | undefined, + e2eiActivatedAt: number, + teamGracePeriodDuration: number, + activeWallClock: WallClock = wallClock, +): number { + const {end} = getGracePeriod(identity, e2eiActivatedAt, teamGracePeriodDuration, activeWallClock); + return Math.max(0, end - activeWallClock.currentTimestampInMilliseconds); } export function hasGracePeriodStartedForSelfClient( identity: WireIdentity | undefined, e2eiActivatedAt: number, teamGracePeriodDuration: number, + activeWallClock: WallClock = wallClock, ) { - const deadline = getGracePeriod(identity, e2eiActivatedAt, teamGracePeriodDuration); - return Date.now() >= deadline.start; + const deadline = getGracePeriod(identity, e2eiActivatedAt, teamGracePeriodDuration, activeWallClock); + return activeWallClock.currentTimestampInMilliseconds >= deadline.start; } diff --git a/apps/webapp/src/script/clock/fakeWallClock.test.ts b/apps/webapp/src/script/clock/fakeWallClock.test.ts new file mode 100644 index 00000000000..28acb845da9 --- /dev/null +++ b/apps/webapp/src/script/clock/fakeWallClock.test.ts @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createFakeWallClock} from './fakeWallClock'; + +describe('createFakeWallClock', () => { + it('starts at zero by default', () => { + const fakeWallClock = createFakeWallClock(); + + expect(typeof fakeWallClock.currentTimestampInMilliseconds).toBe('number'); + expect(fakeWallClock.currentDate).toBeInstanceOf(Date); + expect(fakeWallClock.currentTimestampInMilliseconds).toBe(0); + expect(fakeWallClock.currentDate.getTime()).toBe(0); + }); + + it('starts with the provided initial timestamp', () => { + const initialCurrentTimestampInMilliseconds = 1_704_067_200_000; + const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds}); + + expect(typeof fakeWallClock.currentTimestampInMilliseconds).toBe('number'); + expect(fakeWallClock.currentDate).toBeInstanceOf(Date); + expect(fakeWallClock.currentTimestampInMilliseconds).toBe(initialCurrentTimestampInMilliseconds); + expect(fakeWallClock.currentDate.getTime()).toBe(initialCurrentTimestampInMilliseconds); + }); + + it('sets the current timestamp in milliseconds', () => { + const fakeWallClock = createFakeWallClock(); + const nextTimestampInMilliseconds = 1_800_000; + + fakeWallClock.setCurrentTimestampInMilliseconds(nextTimestampInMilliseconds); + + expect(fakeWallClock.currentTimestampInMilliseconds).toBe(nextTimestampInMilliseconds); + expect(fakeWallClock.currentDate.getTime()).toBe(nextTimestampInMilliseconds); + }); + + it('advances current timestamp by the provided delay', () => { + const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 10_000}); + + fakeWallClock.advanceByMilliseconds(500); + fakeWallClock.advanceByMilliseconds(1_500); + + expect(fakeWallClock.currentTimestampInMilliseconds).toBe(12_000); + expect(fakeWallClock.currentDate.getTime()).toBe(12_000); + }); + + it('returns a new date instance for each call', () => { + const fakeWallClock = createFakeWallClock({initialCurrentTimestampInMilliseconds: 100}); + + const firstCurrentDate = fakeWallClock.currentDate; + const secondCurrentDate = fakeWallClock.currentDate; + + expect(firstCurrentDate).not.toBe(secondCurrentDate); + expect(firstCurrentDate.getTime()).toBe(100); + expect(secondCurrentDate.getTime()).toBe(100); + }); +}); diff --git a/apps/webapp/src/script/clock/fakeWallClock.ts b/apps/webapp/src/script/clock/fakeWallClock.ts new file mode 100644 index 00000000000..a3c4194cf9d --- /dev/null +++ b/apps/webapp/src/script/clock/fakeWallClock.ts @@ -0,0 +1,53 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {WallClock} from './wallClock'; + +type FakeWallClockOptions = { + readonly initialCurrentTimestampInMilliseconds?: number; +}; + +export type FakeWallClock = WallClock & { + setCurrentTimestampInMilliseconds(nextTimestampInMilliseconds: number): void; + advanceByMilliseconds(delayInMilliseconds: number): void; +}; + +export function createFakeWallClock(options: FakeWallClockOptions = {}): FakeWallClock { + const {initialCurrentTimestampInMilliseconds = 0} = options; + + let currentTimestampInMilliseconds = initialCurrentTimestampInMilliseconds; + + return { + get currentTimestampInMilliseconds() { + return currentTimestampInMilliseconds; + }, + + get currentDate() { + return new Date(currentTimestampInMilliseconds); + }, + + setCurrentTimestampInMilliseconds(nextTimestampInMilliseconds: number) { + currentTimestampInMilliseconds = nextTimestampInMilliseconds; + }, + + advanceByMilliseconds(delayInMilliseconds: number) { + currentTimestampInMilliseconds += delayInMilliseconds; + }, + }; +} diff --git a/apps/webapp/src/script/clock/wallClock.test.ts b/apps/webapp/src/script/clock/wallClock.test.ts new file mode 100644 index 00000000000..6b1d7169ce7 --- /dev/null +++ b/apps/webapp/src/script/clock/wallClock.test.ts @@ -0,0 +1,57 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import timers from 'node:timers/promises'; + +import {createWallClock} from './wallClock'; + +describe('wall clock', () => { + it('returns the current timestamp in milliseconds', () => { + const lowerTimestampBound = Date.now(); + const wallClock = createWallClock(); + const currentTimestamp = wallClock.currentTimestampInMilliseconds; + const upperTimestampBound = Date.now(); + + expect(typeof currentTimestamp).toBe('number'); + expect(currentTimestamp).toBeGreaterThanOrEqual(lowerTimestampBound); + expect(currentTimestamp).toBeLessThanOrEqual(upperTimestampBound); + }); + + it('returns the current date based on Date.now()', () => { + const lowerTimestampBound = Date.now(); + const wallClock = createWallClock(); + const currentDate = wallClock.currentDate; + const upperTimestampBound = Date.now(); + + expect(currentDate).toBeInstanceOf(Date); + expect(currentDate.getTime()).toBeGreaterThanOrEqual(lowerTimestampBound); + expect(currentDate.getTime()).toBeLessThanOrEqual(upperTimestampBound); + }); + + it('returns a new date instance for each call', async () => { + const wallClock = createWallClock(); + + const firstCurrentDate = wallClock.currentDate; + await timers.setTimeout(1); + const secondCurrentDate = wallClock.currentDate; + + expect(firstCurrentDate).not.toBe(secondCurrentDate); + expect(secondCurrentDate.getTime()).toBeGreaterThanOrEqual(firstCurrentDate.getTime()); + }); +}); diff --git a/apps/webapp/src/script/clock/wallClock.ts b/apps/webapp/src/script/clock/wallClock.ts new file mode 100644 index 00000000000..b4b086907ce --- /dev/null +++ b/apps/webapp/src/script/clock/wallClock.ts @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export type WallClock = { + readonly currentTimestampInMilliseconds: number; + readonly currentDate: Date; +}; + +export function createWallClock(): WallClock { + return { + get currentTimestampInMilliseconds() { + return Date.now(); + }, + + get currentDate() { + return new Date(Date.now()); + }, + }; +} diff --git a/apps/webapp/src/script/components/ConfigToolbar/ConfigToolbar.tsx b/apps/webapp/src/script/components/ConfigToolbar/ConfigToolbar.tsx index 3fff9dc5d50..37dc4fb7816 100644 --- a/apps/webapp/src/script/components/ConfigToolbar/ConfigToolbar.tsx +++ b/apps/webapp/src/script/components/ConfigToolbar/ConfigToolbar.tsx @@ -309,6 +309,7 @@ export function ConfigToolbar() { +
{renderAvsSwitch()}
diff --git a/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts b/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts index bee1997cbdb..cc182199f70 100644 --- a/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts +++ b/apps/webapp/src/script/repositories/conversation/ConversationRepository.ts @@ -2222,13 +2222,24 @@ export class ConversationRepository { }): Promise => { this.logger.info('Ensuring conversation exists', {conversationId, groupId, epoch}); if (await this.conversationService.mlsGroupExistsLocally(groupId)) { - this.logger.info('Conversation already exists locally', {conversationId, groupId, epoch}); - if (epoch === 0) { + const coreCryptoEpochNumber = await core.service?.mls?.getEpoch(groupId); + this.logger.info('Conversation already exists locally', {conversationId, groupId, epoch, coreCryptoEpochNumber}); + if (coreCryptoEpochNumber === 0) { if (!retry) { - this.logger.error('Epoch is 0, but retry is false, not retrying again', {conversationId, groupId, epoch}); + this.logger.error('Epoch is 0, but retry is false, not retrying again', { + conversationId, + groupId, + epoch, + coreCryptoEpochNumber, + }); return; } - return this.recoverFromLocalUnestablishedMLSConversations({conversationId, groupId, epoch, core}); + return this.recoverFromLocalUnestablishedMLSConversations({ + conversationId, + groupId, + epoch: coreCryptoEpochNumber, + core, + }); } return; } diff --git a/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts b/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts index 0f7071b66e3..331ee76bc2b 100644 --- a/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts +++ b/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.test.ts @@ -18,7 +18,7 @@ */ import {CONVERSATION_PROTOCOL} from '@wireapp/api-client/lib/team'; -import {E2eiConversationState} from '@wireapp/core/lib/messagingProtocols/mls'; +import {CredentialType, E2eiConversationState} from '@wireapp/core/lib/messagingProtocols/mls'; import {Conversation} from 'Repositories/entity/Conversation'; import * as e2eIdentity from 'src/script/E2EIdentity'; @@ -34,6 +34,7 @@ import {ConversationVerificationState} from '../../ConversationVerificationState jest.mock('src/script/E2EIdentity', () => ({ ...jest.requireActual('src/script/E2EIdentity'), getConversationVerificationState: jest.fn(), + getActiveWireIdentity: jest.fn(), E2EIHandler: { getInstance: jest.fn().mockReturnValue({ isE2EIEnabled: jest.fn(), @@ -42,6 +43,26 @@ jest.mock('src/script/E2EIdentity', () => ({ })); describe('MLSConversationVerificationStateHandler', () => { + const createRevokedWireIdentity = (): e2eIdentity.WireIdentity => ({ + x509Identity: { + free: jest.fn(), + certificate: '', + displayName: 'John Doe', + domain: 'wire.com', + handle: 'wireapp://%40john.doe@wire.com', + notAfter: BigInt(0), + notBefore: BigInt(0), + serialNumber: '', + [Symbol.dispose]: () => {}, + }, + thumbprint: '', + credentialType: CredentialType.X509, + status: e2eIdentity.MLSStatuses.REVOKED, + clientId: 'client-id', + deviceId: 'device-id', + qualifiedUserId: {id: 'user-id', domain: 'wire.com'}, + }); + const conversationState = new ConversationState(); let core: Core; const e2eiHandler = e2eIdentity.E2EIHandler.getInstance(); @@ -128,6 +149,62 @@ describe('MLSConversationVerificationStateHandler', () => { expect(core.service?.mls?.on).not.toHaveBeenCalled(); }); + describe('handleNewRevocationList', () => { + it('should check for self certificate revocation for tenant-prefixed CRL hosts', async () => { + const localConversationState = new ConversationState(); + let triggerCrlChanged: (...args: {domain: string}[]) => void = () => {}; + const onSelfClientCertificateRevoked = jest.fn().mockResolvedValue(undefined); + + jest.spyOn(e2eIdentity, 'getActiveWireIdentity').mockResolvedValue(createRevokedWireIdentity()); + jest.spyOn(core.service!.e2eIdentity!, 'on').mockImplementation((event, listener) => { + if (event === 'crlChanged') { + triggerCrlChanged = listener; + } + return core.service!.e2eIdentity!; + }); + + new MLSConversationVerificationStateHandler( + 'wire.com', + () => {}, + onSelfClientCertificateRevoked, + localConversationState, + core, + ); + + await triggerCrlChanged({domain: 'tenant-a.wire.com'}); + + expect(e2eIdentity.getActiveWireIdentity).toHaveBeenCalledTimes(1); + expect(onSelfClientCertificateRevoked).toHaveBeenCalledTimes(1); + }); + + it('should not check for self certificate revocation for non-matching CRL hosts', async () => { + const localConversationState = new ConversationState(); + let triggerCrlChanged: (...args: {domain: string}[]) => void = () => {}; + const onSelfClientCertificateRevoked = jest.fn().mockResolvedValue(undefined); + + jest.spyOn(e2eIdentity, 'getActiveWireIdentity').mockResolvedValue(createRevokedWireIdentity()); + jest.spyOn(core.service!.e2eIdentity!, 'on').mockImplementation((event, listener) => { + if (event === 'crlChanged') { + triggerCrlChanged = listener; + } + return core.service!.e2eIdentity!; + }); + + new MLSConversationVerificationStateHandler( + 'wire.com', + () => {}, + onSelfClientCertificateRevoked, + localConversationState, + core, + ); + + await triggerCrlChanged({domain: 'tenant-a.example.com'}); + + expect(e2eIdentity.getActiveWireIdentity).not.toHaveBeenCalled(); + expect(onSelfClientCertificateRevoked).not.toHaveBeenCalled(); + }); + }); + describe('checkConversationVerificationState', () => { it('should reset to unverified if mls group does not exist anymore', async () => { let triggerEpochChange: Function = () => {}; diff --git a/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts b/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts index 868a8a5a174..8285281f5c2 100644 --- a/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts +++ b/apps/webapp/src/script/repositories/conversation/ConversationVerificationStateHandler/MLS/MLSStateHandler.ts @@ -115,7 +115,8 @@ export class MLSConversationVerificationStateHandler { * This function checks if self client certificate is revoked */ private handleNewRevocationList = async (domain: string): Promise => { - if (domain === this.selfDomain) { + // CRL hosts can be tenant-prefixed, so a suffix match is enough to scope this event to the backend domain. + if (domain.endsWith(this.selfDomain)) { // The crl of the self user has changed, we need to check if the self client certificate is revoked const activeIdentity = await getActiveWireIdentity(); if (activeIdentity?.status === MLSStatuses.REVOKED) { diff --git a/apps/webapp/src/script/util/DebugUtil.ts b/apps/webapp/src/script/util/DebugUtil.ts index bfae1964d73..e1f5674a902 100644 --- a/apps/webapp/src/script/util/DebugUtil.ts +++ b/apps/webapp/src/script/util/DebugUtil.ts @@ -433,6 +433,20 @@ export class DebugUtil { E2EIHandler.getInstance().certificateTtl = ttl; } + /** + * Used by QA/developers to fetch revocation data on demand. + * Available in console via window.wire.app.debug.refreshE2EIRevocationData(). + */ + async refreshE2EIRevocationData(): Promise { + const e2eIdentityService = this.core.service?.e2eIdentity; + + if (!e2eIdentityService) { + throw new Error('E2EI service is not available'); + } + + await e2eIdentityService.refreshRevocationData(); + } + /** * Used by QA test automation: Will allow the webapp to generate fake link previews when sending a link in a message. */ diff --git a/apps/webapp/src/types/i18n.d.ts b/apps/webapp/src/types/i18n.d.ts index 926ddc6574f..647a8a7bdda 100644 --- a/apps/webapp/src/types/i18n.d.ts +++ b/apps/webapp/src/types/i18n.d.ts @@ -1053,15 +1053,15 @@ declare module 'I18n/en-US.json' { 'groupCreationPreferencesNonFederatingLeave': `Discard Group Creation`; 'groupCreationPreferencesNonFederatingMessage': `People from backends {backends} can’t join the same group conversation, as their backends can’t communicate with each other. To create the group, remove affected participants. [link]Learn more[/link]`; 'groupCreationPreferencesPlaceholder': `Group name`; - 'groupDetailsActionDelete': `Delete group`; - 'groupDetailsActionLeave': `Leave group`; + 'groupDetailsActionDelete': `Delete conversation`; + 'groupDetailsActionLeave': `Leave conversation`; 'groupParticipantActionBlock': `Block…`; 'groupParticipantActionCancelRequest': `Cancel request…`; 'groupParticipantActionDevices': `Devices`; 'groupParticipantActionDevicesGoBack': `Go back to device details`; 'groupParticipantActionIgnoreRequest': `Ignore request`; 'groupParticipantActionIncomingRequest': `Accept request`; - 'groupParticipantActionLeave': `Leave group…`; + 'groupParticipantActionLeave': `Leave conversation…`; 'groupParticipantActionOpenConversation': `Open conversation`; 'groupParticipantActionPending': `Pending`; 'groupParticipantActionRemove': `Remove from group…`; @@ -1070,7 +1070,7 @@ declare module 'I18n/en-US.json' { 'groupParticipantActionStartConversation': `Start conversation`; 'groupParticipantActionUnblock': `Unblock…`; 'groupSizeInfo': `Up to {count} people can join a group conversation.`; - 'groupsPopoverLeave': `Leave group`; + 'groupsPopoverLeave': `Leave conversation`; 'guestLinkDisabled': `Generating guest links is not allowed in your team.`; 'guestLinkDisabledByOtherTeam': `You can\'t generate a guest link in this conversation, as it has been created by someone from another team and this team is not allowed to use guest links.`; 'guestLinkPasswordModal.conversationPasswordProtected': `This conversation is password protected.`; @@ -1175,6 +1175,19 @@ declare module 'I18n/en-US.json' { 'layoutSidebarContent': `Connect, message, and share files with ease, protected by the industry\'s most secure end-to-end encryption`; 'layoutSidebarHeader': `Collaborate without Compromise`; 'layoutSidebarLink': `Learn more`; + 'leaveGroupAdminModalCancelAction': `Cancel`; + 'leaveGroupAdminModalClearContent': `Also clear the content`; + 'leaveGroupAdminModalClose': `Close window \'Leave {name}\'`; + 'leaveGroupAdminModalDeleteAction': `Delete conversation`; + 'leaveGroupAdminModalLeaveAction': `Leave`; + 'leaveGroupAdminModalMessageNoEligibleFirstPart': `You\'re currently the only admin in this group with no other participant eligible to be promoted as admin.`; + 'leaveGroupAdminModalMessageNoEligibleSecondPart': `Add at least one additional admin, after that you can leave this group. If you no longer need the group or its content, consider deleting it instead.`; + 'leaveGroupAdminModalMessageWithEligibleFirstPart': `You\'re currently the only admin in this group. To prevent the group from being left without management after you leave, promote another participant to admin.`; + 'leaveGroupAdminModalMessageWithEligibleSecondPart': `If you no longer need the group or its content, consider deleting it instead.`; + 'leaveGroupAdminModalNewAdminLabel': `New admin`; + 'leaveGroupAdminModalPromoteAction': `Leave conversation`; + 'leaveGroupAdminModalSearchPlaceholder': `Enter a name`; + 'leaveGroupAdminModalTitle': `Leave {name}?`; 'legalHoldActivated': `This conversation is under legal hold`; 'legalHoldActivatedLearnMore': `Learn more`; 'legalHoldDeactivated': `Legal hold deactivated for this conversation`; diff --git a/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts b/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts index 9aeae84e33e..a879c6d95c8 100644 --- a/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts +++ b/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts @@ -43,8 +43,8 @@ export class ConversationDetailsPage { this.archiveButton = this.conversationDetails.locator(selectByDataAttribute('do-archive')); this.blockConversationButton = this.conversationDetails.locator(selectByDataAttribute('do-block')); this.clearConversationContentButton = this.conversationDetails.getByRole('button', {name: 'Clear Content'}); - this.selectedSearchList = this.page.locator(selectByDataAttribute('selected-search-list')); - this.searchList = this.page.locator(selectByDataAttribute('search-list')); + this.selectedSearchList = this.page.getByTestId('selected-search-list'); + this.searchList = this.page.locator('#add-participants').getByRole('list'); } async waitForSidebar() { diff --git a/libraries/core/src/conversation/ConversationService/ConversationService.test.ts b/libraries/core/src/conversation/ConversationService/ConversationService.test.ts index d8df2e92738..47d09bf2528 100644 --- a/libraries/core/src/conversation/ConversationService/ConversationService.test.ts +++ b/libraries/core/src/conversation/ConversationService/ConversationService.test.ts @@ -679,6 +679,90 @@ describe('ConversationService', () => { expect(conversationService.joinByExternalCommit).not.toHaveBeenCalled(); expect(establishedConversation.epoch).toEqual(updatedEpoch); }); + + it('allows establishing cross-domain MLS 1:1 conversations', async () => { + const [conversationService, {apiClient, mlsService}] = await buildConversationService(); + + const mockConversationId = {id: 'mock-conversation-id', domain: 'remote.wire.com'}; + const mockGroupId = 'mock-group-id'; + + const selfUser = {user: {id: 'self-user-id', domain: 'local.wire.com'}, client: 'self-user-client-id'}; + const otherUserId = {id: 'other-user-id', domain: 'remote.wire.com'}; + + const remoteEpoch = 0; + const updatedEpoch = 1; + + jest.spyOn(apiClient.api.conversation, 'getMLS1to1Conversation').mockResolvedValueOnce({ + qualified_id: mockConversationId, + protocol: CONVERSATION_PROTOCOL.MLS, + epoch: remoteEpoch, + group_id: mockGroupId, + } as unknown as MLSConversation); + + jest.spyOn(apiClient.api.conversation, 'getMLS1to1Conversation').mockResolvedValueOnce({ + qualified_id: mockConversationId, + protocol: CONVERSATION_PROTOCOL.MLS, + epoch: updatedEpoch, + group_id: mockGroupId, + } as unknown as MLSConversation); + + jest.spyOn(mlsService, 'wipeConversation'); + + const establishedConversation = await conversationService.establishMLS1to1Conversation( + mockGroupId, + selfUser, + otherUserId, + ); + + expect(mlsService.register1to1Conversation).toHaveBeenCalledTimes(1); + expect(mlsService.register1to1Conversation).toHaveBeenCalledWith(mockGroupId, otherUserId, selfUser, undefined); + expect(conversationService.joinByExternalCommit).not.toHaveBeenCalled(); + expect(establishedConversation.epoch).toEqual(updatedEpoch); + }); + }); + + describe('domain mismatch guards', () => { + it('throws when establishing MLS group conversation with mismatched self and conversation domains', async () => { + const [conversationService, {mlsService}] = await buildConversationService(); + + const groupId = 'group-domain-mismatch-establish'; + const selfUserId = {id: 'self-user-id', domain: 'local.wire.com'}; + const conversationQualifiedId = {id: PayloadHelper.getUUID(), domain: 'staging.zinfra.io'}; + + await expect( + conversationService.establishMLSGroupConversation( + groupId, + [], + selfUserId, + 'self-client-id', + conversationQualifiedId, + ), + ).rejects.toThrow('does not match conversation domain'); + + expect(mlsService.registerConversation).not.toHaveBeenCalled(); + }); + + it('throws when resetting MLS conversation if self user domain mismatches conversation domain', async () => { + const [conversationService, {apiClient}] = await buildConversationService(); + + const conversationId = {id: PayloadHelper.getUUID(), domain: 'staging.zinfra.io'}; + jest.spyOn(apiClient, 'domain', 'get').mockReturnValue('staging.zinfra.io'); + + jest.spyOn(apiClient.api.conversation, 'getConversation').mockResolvedValueOnce({ + qualified_id: {id: conversationId.id, domain: 'local.wire.com'}, + protocol: CONVERSATION_PROTOCOL.MLS, + epoch: 1, + group_id: 'group-domain-mismatch-reset', + } as unknown as Conversation); + + const resetSpy = jest.spyOn(apiClient.api.conversation, 'resetMLSConversation'); + + await expect((conversationService as any).resetMLSConversation(conversationId)).rejects.toThrow( + 'does not match conversation domain', + ); + + expect(resetSpy).not.toHaveBeenCalled(); + }); }); describe('handleEvent', () => { @@ -1075,6 +1159,31 @@ describe('ConversationService', () => { expect(conversationService.addUsersToMLSConversation).not.toHaveBeenCalled(); }); + + it('throws when self user domain does not match conversation domain', async () => { + const [conversationService, {mlsService}] = await buildConversationService(); + const selfUserId = {id: 'self-user-id', domain: 'local.wire.com'}; + + const mockConversationId = {id: PayloadHelper.getUUID(), domain: 'staging.zinfra.io'}; + const mockGroupId = 'groupId'; + const otherUsersToAdd = Array(3) + .fill(0) + .map(() => ({id: PayloadHelper.getUUID(), domain: 'local.wire.com'})); + + const addUsersSpy = jest.spyOn(conversationService, 'addUsersToMLSConversation'); + + await expect( + conversationService.tryEstablishingMLSGroup({ + conversationId: mockConversationId, + groupId: mockGroupId, + qualifiedUsers: otherUsersToAdd, + selfUserId, + }), + ).rejects.toThrow('does not match conversation domain'); + + expect(mlsService.tryEstablishingMLSGroup).not.toHaveBeenCalled(); + expect(addUsersSpy).not.toHaveBeenCalled(); + }); }); describe('reactToKeyMaterialUpdateFailure', () => { diff --git a/libraries/core/src/conversation/ConversationService/ConversationService.ts b/libraries/core/src/conversation/ConversationService/ConversationService.ts index 546f835a896..a59464ffce5 100644 --- a/libraries/core/src/conversation/ConversationService/ConversationService.ts +++ b/libraries/core/src/conversation/ConversationService/ConversationService.ts @@ -138,6 +138,16 @@ export class ConversationService extends TypedEventEmitter { return this._mlsService; } + private validateDomainMatch(selfUserDomain: string, conversationDomain: string): void { + if (selfUserDomain === conversationDomain) { + return; + } + + const errorMessage = `Self user domain (${selfUserDomain}) does not match conversation domain (${conversationDomain})`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + /** * Get a fresh list from backend of clients for all the participants of the conversation. * @fixme there are some case where this method is not enough to detect removed devices @@ -363,6 +373,39 @@ export class ConversationService extends TypedEventEmitter { selfClientId: string, conversationQualifiedId: QualifiedId, ): Promise { + return this.MLSRecoveryOrchestrator.execute({ + context: { + operationName: OperationName.establishGroup, + qualifiedConversationId: conversationQualifiedId, + groupId, + }, + callBack: () => { + return this.performEstablishMLSGroupConversationAPI({ + groupId, + userIdsToAdd, + selfUserId, + selfClientId, + conversationQualifiedId, + }); + }, + }); + } + + private async performEstablishMLSGroupConversationAPI({ + groupId, + userIdsToAdd, + selfUserId, + selfClientId, + conversationQualifiedId, + }: { + groupId: string; + userIdsToAdd: QualifiedId[]; + selfUserId: QualifiedId; + selfClientId: string; + conversationQualifiedId: QualifiedId; + }): Promise { + this.validateDomainMatch(selfUserId.domain, conversationQualifiedId.domain); + const failures = await this.mlsService.registerConversation(groupId, userIdsToAdd.concat(selfUserId), { creator: { user: selfUserId, @@ -588,9 +631,27 @@ export class ConversationService extends TypedEventEmitter { private async resetMLSConversation(conversationId: QualifiedId): Promise { this.logger.info(`Resetting MLS conversation with id ${conversationId.id}`); - // STEP 1: Fetch the conversation to retrieve the group ID & epoch + // STEP 1: fetch self user info + this.logger.info( + `Re-establishing the conversation by re-adding all members (conversation_id: ${conversationId.id})`, + ); + const {validatedClientId: clientId, userId, domain: selfUserDomain} = this.apiClient; + + if (!selfUserDomain) { + const errorMessage = 'Could not find domain of the self user'; + this.logger.error(errorMessage, {conversationId}); + throw new Error(errorMessage); + } + + // STEP 2: Fetch the conversation to retrieve the group ID & epoch const conversation = await this.apiClient.api.conversation.getConversation(conversationId); - const {group_id: groupId, epoch} = conversation; + const { + group_id: groupId, + epoch, + qualified_id: {domain: conversationDomain}, + } = conversation; + + this.validateDomainMatch(selfUserDomain, conversationDomain); if (!groupId || !epoch) { const errorMessage = 'Could not find group id or epoch for the conversation'; @@ -598,26 +659,20 @@ export class ConversationService extends TypedEventEmitter { throw new Error(errorMessage); } - // STEP 2: Request backend to reset the conversation + // STEP 3: Request backend to reset the conversation this.logger.info(`Requesting backend to reset the conversation (group_id: ${groupId}, epoch: ${String(epoch)})`); await this.apiClient.api.conversation.resetMLSConversation({ epoch, groupId, }); - // STEP 3: fetch self user info - this.logger.info( - `Re-establishing the conversation by re-adding all members (conversation_id: ${conversationId.id})`, - ); - const {validatedClientId: clientId, userId, domain} = this.apiClient; - - if (!userId || !domain) { + if (!userId || !selfUserDomain) { const errorMessage = 'Could not find userId or domain of the self user'; this.logger.error(errorMessage, {conversationId}); throw new Error(errorMessage); } - const selfUserQualifiedId = {id: userId, domain}; + const selfUserQualifiedId = {id: userId, domain: selfUserDomain}; // STEP 4: Fetch the updated conversation data from backend to retrieve the new group ID const updatedConversation = await this.apiClient.api.conversation.getConversation(conversationId); @@ -881,6 +936,8 @@ export class ConversationService extends TypedEventEmitter { qualifiedUsers: QualifiedId[]; }): Promise { try { + this.validateDomainMatch(selfUserId.domain, conversationId.domain); + const wasGroupEstablishedBySelfClient = await this.mlsService.tryEstablishingMLSGroup(groupId); if (!wasGroupEstablishedBySelfClient) { diff --git a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.test.ts b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.test.ts index a7954ad1dc3..76826849e97 100644 --- a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.test.ts +++ b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.test.ts @@ -266,5 +266,17 @@ describe('E2EIServiceExternal', () => { expect(transactionContext.e2eiRegisterAcmeCA).not.toHaveBeenCalled(); expect(transactionContext.e2eiRegisterIntermediateCA).toHaveBeenCalledTimes(2); }); + + it('refreshes revocation data on demand', async () => { + const [service, {transactionContext}] = await buildE2EIService('mockedDB3'); + + jest.spyOn(transactionContext, 'e2eiIsPKIEnvSetup').mockResolvedValueOnce(true); + + await service.initialize('https://some.crl.discovery.url'); + expect(transactionContext.e2eiRegisterIntermediateCA).toHaveBeenCalledTimes(2); + + await service.refreshRevocationData(); + expect(transactionContext.e2eiRegisterIntermediateCA).toHaveBeenCalledTimes(4); + }); }); }); diff --git a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.ts b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.ts index b322ca9a0b2..340315ed2e7 100644 --- a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.ts +++ b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceExternal.ts @@ -222,6 +222,22 @@ export class E2EIServiceExternal extends TypedEventEmitter { await this.initialiseCrlDistributionTimers(); } + /** + * Forces an immediate refresh of revocation-related data. + * - Re-fetches and registers federation intermediate certificates + * - Re-validates all known CRL distribution points from local storage + */ + public async refreshRevocationData(): Promise { + await this.registerCrossSignedCertificates(this.acmeService); + + const knownCrlDistributionPoints = await this.coreDatabase.getAll('crls'); + const uniqueDistributionPointUrls = Array.from(new Set(knownCrlDistributionPoints.map(crl => crl.url))); + + for (const distributionPointUrl of uniqueDistributionPointUrls) { + await this.validateCrlDistributionPoint(distributionPointUrl); + } + } + private get acmeService(): AcmeService { if (!this._acmeService) { throw new Error('AcmeService not initialized'); diff --git a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceInternal.ts b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceInternal.ts index 2576ce2ad6f..dd89e12f1bc 100644 --- a/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceInternal.ts +++ b/libraries/core/src/messagingProtocols/mls/E2EIdentityService/E2EIServiceInternal.ts @@ -17,6 +17,7 @@ * */ +import is from '@sindresorhus/is'; import {Decoder} from 'bazinga64'; import {APIClient} from '@wireapp/api-client'; @@ -224,7 +225,7 @@ export class E2EIServiceInternal { }); this.logger.debug('oidc data', oidcData); - if (!oidcData.data.validated) { + if (is.nullOrUndefined(oidcData.data.validated)) { throw new Error('Error while trying to continue OAuth flow. OIDC challenge not validated'); } diff --git a/libraries/core/src/messagingProtocols/mls/recovery/MlsRecoveryOrchestrator.ts b/libraries/core/src/messagingProtocols/mls/recovery/MlsRecoveryOrchestrator.ts index 0b1b0eb7b22..c0bee943cc8 100644 --- a/libraries/core/src/messagingProtocols/mls/recovery/MlsRecoveryOrchestrator.ts +++ b/libraries/core/src/messagingProtocols/mls/recovery/MlsRecoveryOrchestrator.ts @@ -101,6 +101,7 @@ export type PolicyTable = Partial