Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
21 changes: 17 additions & 4 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand All @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 11 additions & 8 deletions apps/webapp/src/script/E2EIdentity/E2EIdentityEnrollment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -230,7 +230,10 @@ export class E2EIHandler extends TypedEventEmitter<Events> {
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) {
Expand Down Expand Up @@ -399,9 +402,9 @@ export class E2EIHandler extends TypedEventEmitter<Events> {
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();
},
Expand Down Expand Up @@ -429,9 +432,9 @@ export class E2EIHandler extends TypedEventEmitter<Events> {
},
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();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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};
Expand All @@ -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;
}
72 changes: 72 additions & 0 deletions apps/webapp/src/script/clock/fakeWallClock.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading