Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/fancy-deserts-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/core-services': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/ai-search': minor
'@rocket.chat/ui-client': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
7 changes: 7 additions & 0 deletions .changeset/four-tigers-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/models': patch
'@rocket.chat/model-typings': patch
'@rocket.chat/meteor': patch
---

Fixes a race condition that left messages permanently undecryptable ("incorrect encryption key") in rooms created with encryption enabled. When several members opened such a room at the same time, each client could independently generate and distribute a different group key. Establishing the room key is now atomic (first-write-wins) on the server, and a client that loses the race discards its locally generated key and adopts the established one instead of encrypting with a divergent key.
5 changes: 5 additions & 0 deletions .changeset/swift-ants-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes dates showing one day earlier for users in negative UTC-offset timezones
81 changes: 81 additions & 0 deletions apps/meteor/client/lib/e2ee/rocketchat.e2e.room.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as Aes from './crypto/aes';
import { E2ERoom } from './rocketchat.e2e.room';
import { sdk } from '../../../app/utils/client/lib/SDKClient';

jest.mock('./crypto/aes');
jest.mock('../../../app/utils/client/lib/SDKClient', () => ({
sdk: { rest: { post: jest.fn() } },
}));
jest.mock('../../../app/utils/client/index', () => ({
Info: { version: '8.7.0' },
}));

class FakeResponse {
constructor(
private readonly body: unknown,
public readonly status = 400,
) {}

clone() {
return this;
}

async json() {
return this.body;
}
}
(global as any).Response = FakeResponse;

const makeRoom = () => new E2ERoom('user-1', { _id: 'room-1', t: 'p', encrypted: true } as any);

describe('E2ERoom.createGroupKey', () => {
beforeEach(() => {
jest.clearAllMocks();
(Aes.generate as jest.Mock).mockResolvedValue('group-key');
(Aes.exportJwk as jest.Mock).mockResolvedValue({ k: 'jwk' });
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should discard the local key and not distribute it when it loses the race', async () => {
(sdk.rest.post as jest.Mock).mockRejectedValueOnce(
new (global as any).Response({ success: false, errorType: 'error-room-e2e-key-already-exists' }),
);

const e2eRoom = makeRoom();
const result = await e2eRoom.createGroupKey();

expect(result).toBe(false);
expect(e2eRoom.groupSessionKey).toBeNull();
expect(e2eRoom.keyID).toBeUndefined();
expect(sdk.rest.post).toHaveBeenCalledTimes(1); // only setRoomKeyID was called
expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.setRoomKeyID', { rid: e2eRoom.roomId, keyID: expect.any(String) });
});

it('should return true and distribute the key when it wins the race', async () => {
(sdk.rest.post as jest.Mock).mockResolvedValue({});
jest.spyOn(E2ERoom.prototype as any, 'encryptGroupKeyForParticipant').mockResolvedValue('mykey');
const encryptForOthersSpy = jest.spyOn(E2ERoom.prototype as any, 'encryptKeyForOtherParticipants').mockResolvedValue(undefined);

const e2eRoom = makeRoom();
const result = await e2eRoom.createGroupKey();

expect(result).toBe(true);
expect(e2eRoom.groupSessionKey).not.toBeNull();

expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.setRoomKeyID', {
rid: e2eRoom.roomId,
keyID: expect.any(String),
});
expect(sdk.rest.post).toHaveBeenCalledWith('/v1/e2e.updateGroupKey', {
rid: e2eRoom.roomId,
uid: e2eRoom.userId,
key: 'mykey',
});

expect(encryptForOthersSpy).toHaveBeenCalledTimes(1);
expect(sdk.rest.post).toHaveBeenCalledTimes(2); // both setRoomKeyID and updateGroupKey are called
});
});
37 changes: 34 additions & 3 deletions apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ const log = createLogger('E2E:Room');
const KEY_ID = Symbol('keyID');
const PAUSED = Symbol('PAUSED');

const isRoomKeyAlreadyExistsError = async (error: Response): Promise<boolean> => {
try {
const body = await error.clone().json();
return body?.errorType === 'error-room-e2e-key-already-exists';
} catch {
return false;
}
};

type Mutations = { [k in E2ERoomState]?: E2ERoomState[] };

const permitedMutations: Mutations = {
Expand Down Expand Up @@ -331,7 +340,12 @@ export class E2ERoom extends Emitter {
span.set('room', room);
if (!room?.e2eKeyId) {
this.setState('CREATING_KEYS');
await this.createGroupKey();
const created = await this.createGroupKey();
if (!created) {
// another member won the race
this.setState('WAITING_KEYS');
return;
}
this.setState('READY');
return;
}
Expand Down Expand Up @@ -420,10 +434,26 @@ export class E2ERoom extends Emitter {
this.keyID = crypto.randomUUID();
}

async createGroupKey() {
private discardGroupKey() {
this.groupSessionKey = null;
this.sessionKeyExportedString = undefined;
this.keyID = undefined as unknown as string;
}

async createGroupKey(): Promise<boolean> {
const span = log.span('createGroupKey');
await this.createNewGroupKey();

await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID });
try {
await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID });
} catch (error) {
if (error instanceof Response && (await isRoomKeyAlreadyExistsError(error))) {
span.info('Room key already established by another member; discarding local key');
this.discardGroupKey();
return false;
}
throw error;
}
const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!);
if (myKey) {
await sdk.rest.post('/v1/e2e.updateGroupKey', {
Expand All @@ -433,6 +463,7 @@ export class E2ERoom extends Emitter {
});
await this.encryptKeyForOtherParticipants();
}
return true;
}

async resetRoomKey() {
Expand Down
31 changes: 30 additions & 1 deletion apps/meteor/client/lib/utils/dateFormat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { formatDate, momentFormatToDateFns } from './dateFormat';
/**
* @jest-environment <rootDir>/tests/environments/timezone.ts
* @jest-environment-options {"timezone": "America/Argentina/Buenos_Aires"}
*/
import { formatDate, momentFormatToDateFns, toDate } from './dateFormat';

describe('momentFormatToDateFns', () => {
it('maps locale tokens', () => {
Expand Down Expand Up @@ -141,3 +145,28 @@ describe('formatDate', () => {
expect(formatDate(sample, 'gggg')).toMatch(/^\d{4}$/);
});
});

describe('toDate', () => {
it('parses a bare yyyy-MM-dd string at LOCAL midnight (not UTC midnight)', () => {
const d = toDate('2026-07-02');
// Pre-fix `new Date('2026-07-02')` (UTC midnight) is the previous day / 21:00 here.
expect(d.getFullYear()).toBe(2026);
expect(d.getMonth()).toBe(6); // July (0-indexed)
expect(d.getDate()).toBe(2);
expect(d.getHours()).toBe(0);
});

it('keeps full ISO datetimes as instants (UTC preserved)', () => {
expect(toDate('2026-07-02T15:30:00.000Z').toISOString()).toBe('2026-07-02T15:30:00.000Z');
});

it('returns Date objects unchanged', () => {
const d = new Date();
expect(toDate(d)).toBe(d);
});

it('accepts epoch milliseconds', () => {
const ms = Date.UTC(2026, 6, 2, 12);
expect(toDate(ms).getTime()).toBe(ms);
});
});
31 changes: 25 additions & 6 deletions apps/meteor/client/lib/utils/dateFormat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { format, formatDistanceToNow, formatDuration, intervalToDuration, differenceInCalendarDays } from 'date-fns';
import { format, formatDistanceToNow, formatDuration, intervalToDuration, differenceInCalendarDays, parse } from 'date-fns';
import type { Locale } from 'date-fns';

export type DateInput = string | Date | number;
Expand Down Expand Up @@ -181,6 +181,27 @@ export const momentFormatToDateFns = (momentFormat: string): string => {
return out;
};

const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* Normalize a date input to a Date.
*
* Calendar-date-only strings (`yyyy-MM-dd`, e.g. produced by an `<input type="date">`)
* are parsed in LOCAL time, the way Moment.js did before the date-fns migration.
* `new Date('2026-07-02')` instead parses as UTC midnight, which renders as the
* previous day in negative-offset timezones (e.g. UTC-3 shows "Jul 1"). Full ISO
* datetimes, numbers and Date objects keep their original instant semantics.
*/
export const toDate = (date: DateInput): Date => {
if (date instanceof Date) {
return date;
}
if (typeof date === 'string' && DATE_ONLY_RE.test(date)) {
return parse(date, 'yyyy-MM-dd', new Date());
}
return new Date(date);
};

const safeFormat = (d: Date, momentFormat: string, locale?: Locale): string => {
const options = {
...(locale && { locale }),
Expand All @@ -195,8 +216,7 @@ const safeFormat = (d: Date, momentFormat: string, locale?: Locale): string => {
};

export const formatDate = (date: DateInput, formatStr: string, locale?: Locale): string => {
const d = typeof date === 'object' && date instanceof Date ? date : new Date(date);
return safeFormat(d, formatStr, locale);
return safeFormat(toDate(date), formatStr, locale);
};

export const formatTimeAgo = (
Expand All @@ -211,7 +231,7 @@ export const formatTimeAgo = (
},
locale?: Locale,
): string => {
const d = typeof date === 'object' && date instanceof Date ? date : new Date(date);
const d = toDate(date);
const now = new Date();
const diffDays = differenceInCalendarDays(now, d);

Expand All @@ -233,8 +253,7 @@ export const formatTimeAgo = (
};

export const formatFromNow = (date: DateInput, addSuffix: boolean, locale?: Locale): string => {
const d = typeof date === 'object' && date instanceof Date ? date : new Date(date);
return formatDistanceToNow(d, { addSuffix, locale });
return formatDistanceToNow(toDate(date), { addSuffix, locale });
};

export const formatDurationMs = (timeMs: number, locale?: Locale): string => {
Expand Down
11 changes: 10 additions & 1 deletion apps/meteor/client/navbar/NavBarNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { FocusScope } from '@react-aria/focus';
import { NavBarGroup, NavBarItem, Box } from '@rocket.chat/fuselage';
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
import { useLayout, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';

import NavBarSearch from './NavBarSearch';
import NavBarAISearch from './NavBarSearch/NavBarAISearch';

const NavbarNavigation = () => {
const { t } = useTranslation();
Expand All @@ -13,7 +15,14 @@ const NavbarNavigation = () => {
return (
<Box display='flex' flexGrow={1} justifyContent='center'>
<FocusScope>
<NavBarSearch />
<FeaturePreview feature='aiSearch'>
<FeaturePreviewOff>
<NavBarSearch />
</FeaturePreviewOff>
<FeaturePreviewOn>
<NavBarAISearch />
</FeaturePreviewOn>
</FeaturePreview>
</FocusScope>
{!isMobile && (
<Box marginInlineEnd={8}>
Expand Down
Loading
Loading