From 5b8e963fa3457427eb66b2d2a12bc1b8a2d6774e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 12 Aug 2025 10:11:32 -0300 Subject: [PATCH 001/251] stub --- packages/e2e-crypto-core/README.md | 98 ++++++++++++++ packages/e2e-crypto-core/package.json | 35 +++++ .../e2e-crypto-core/src/core/doubleRatchet.ts | 127 ++++++++++++++++++ .../e2e-crypto-core/src/core/keyManagement.ts | 71 ++++++++++ .../e2e-crypto-core/src/core/messageCodec.ts | 68 ++++++++++ .../src/core/messageIntegrity.ts | 26 ++++ .../src/core/replayProtection.ts | 32 +++++ .../src/examples/exampleProviders.ts | 121 +++++++++++++++++ packages/e2e-crypto-core/src/index.ts | 8 ++ .../e2e-crypto-core/src/protocol/session.ts | 52 +++++++ .../src/providers/interfaces.ts | 83 ++++++++++++ packages/e2e-crypto-core/src/types.ts | 92 +++++++++++++ packages/e2e-crypto-core/tsconfig.build.json | 18 +++ 13 files changed, 831 insertions(+) create mode 100644 packages/e2e-crypto-core/README.md create mode 100644 packages/e2e-crypto-core/package.json create mode 100644 packages/e2e-crypto-core/src/core/doubleRatchet.ts create mode 100644 packages/e2e-crypto-core/src/core/keyManagement.ts create mode 100644 packages/e2e-crypto-core/src/core/messageCodec.ts create mode 100644 packages/e2e-crypto-core/src/core/messageIntegrity.ts create mode 100644 packages/e2e-crypto-core/src/core/replayProtection.ts create mode 100644 packages/e2e-crypto-core/src/examples/exampleProviders.ts create mode 100644 packages/e2e-crypto-core/src/index.ts create mode 100644 packages/e2e-crypto-core/src/protocol/session.ts create mode 100644 packages/e2e-crypto-core/src/providers/interfaces.ts create mode 100644 packages/e2e-crypto-core/src/types.ts create mode 100644 packages/e2e-crypto-core/tsconfig.build.json diff --git a/packages/e2e-crypto-core/README.md b/packages/e2e-crypto-core/README.md new file mode 100644 index 0000000000000..46e1e13e99b36 --- /dev/null +++ b/packages/e2e-crypto-core/README.md @@ -0,0 +1,98 @@ +# @rocket.chat/e2e-crypto-core + +Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: + +- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) +- Double Ratchet skeleton for forward secrecy +- Group key derivation & rotation helpers +- Message serialization & integrity (MAC) helpers +- Replay protection utilities +- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage + +> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. + +## Design Principles + +1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. +2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. +3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). +4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. +5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. + +## Core Modules + +| Module | Purpose | +| ------ | ------- | +| `providers/interfaces.ts` | Abstract provider contracts | +| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | +| `core/keyManagement.ts` | Identity & group key helpers | +| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | +| `core/messageIntegrity.ts` | MAC computation / verification | +| `core/replayProtection.ts` | Replay cache + ID helpers | +| `protocol/session.ts` | High-level session manager wrapper | + +## Provider Interfaces + +Implement and inject: + +```ts +import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; + +const ctx: ProviderContext = { + crypto: /* your CryptoProvider */, + rng: /* RandomnessProvider */, + hash: /* HashProvider */, + storage: /* KeyStorageProvider */, + net: /* optional NetworkProvider */, +}; +``` + +## Basic Usage (1:1 Session) + +```ts +import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; + +// ctx: ProviderContext injected +const keyMgr = new KeyManager(ctx); +await keyMgr.ensureIdentityKey(); + +// Assume we fetched peer pre-key bundle externally +const sessionMgr = new SessionManager(ctx); +const init = { + theirIdentityKey: peerBundle.identityKey, + theirSignedPreKey: peerBundle.signedPreKey, + theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], + isInitiator: true, +}; + +// Encrypt +const plaintext = new TextEncoder().encode('hello'); +const payload = await sessionMgr.encrypt('peer-user-id', plaintext); +const wire = serializePayload(payload); // send over DDP / WebSocket / REST + +// Decrypt (other side) +const received = deserializePayload(wire); +const result = await sessionMgr.decrypt('peer-user-id', received); +console.log(new TextDecoder().decode(result.plaintext)); +``` + +## Group Messaging (Conceptual) + +Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. + +## Error Handling + +All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. + +## Extending / Hardening + +Production needs (out of scope of this skeleton): +- Skipped message key handling & storage +- Full DH ratchet step logic & header validation +- Signature verification of pre-key bundles +- Authentic secondary MAC with independent key material +- More robust replay filtering (e.g. bloom filter) +- Formal test vectors & interoperability tests + +## License +MIT diff --git a/packages/e2e-crypto-core/package.json b/packages/e2e-crypto-core/package.json new file mode 100644 index 0000000000000..669d2b7ccad1d --- /dev/null +++ b/packages/e2e-crypto-core/package.json @@ -0,0 +1,35 @@ +{ + "name": "@rocket.chat/e2e-crypto-core", + "version": "0.1.0", + "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.json --module commonjs --outDir dist-cjs && node ./scripts/postbuild.cjs", + "clean": "rimraf dist dist-cjs", + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "~5.8.3", + "rimraf": "^5.0.5", + "eslint": "^9.10.0", + "vitest": "^2.0.0" + }, + "peerDependencies": {}, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/e2e-crypto-core/src/core/doubleRatchet.ts b/packages/e2e-crypto-core/src/core/doubleRatchet.ts new file mode 100644 index 0000000000000..8b0dc5ff0cb92 --- /dev/null +++ b/packages/e2e-crypto-core/src/core/doubleRatchet.ts @@ -0,0 +1,127 @@ +import type { ByteArray, RatchetStateSnapshot, EncryptedPayload, RatchetHeader, DecryptionResult } from '../types'; +import type { ProviderContext } from '../providers/interfaces'; +import { ProtocolError } from '../types'; + +/* Minimal Double Ratchet skeleton (sans-IO). Focus: state transitions & KDF chaining. */ + +export interface InitializeSessionParams { + theirIdentityKey: ByteArray; + theirSignedPreKey: ByteArray; + theirOneTimePreKey?: ByteArray; + isInitiator: boolean; +} + +export interface RatchetSession { + peerId: string; // application-level peer identifier + state: RatchetStateSnapshot; +} + +const VERSION = 1; + +export class DoubleRatchetEngine { + constructor(private readonly ctx: ProviderContext) {} + + async initialize(peerId: string, params: InitializeSessionParams): Promise { + // Generate our initial ephemeral + const ourEphemeral = await this.ctx.crypto.generateX25519KeyPair(); + // Perform initial DHs (IK + SPK [+ OPK]) simplified to one combined secret for brevity + const dh1 = await this.ctx.crypto.diffieHellman(ourEphemeral.privateKey, params.theirSignedPreKey); + let combined = dh1; + if (params.theirOneTimePreKey) { + const dh2 = await this.ctx.crypto.diffieHellman(ourEphemeral.privateKey, params.theirOneTimePreKey); + combined = concat(combined, dh2); + } + const rootKey = await this.deriveRootKey(combined); + const chain = await this.ctx.crypto.kdf(rootKey, toBytes('INIT')); + + const snapshot: RatchetStateSnapshot = { + rootKey: rootKey, + sendChainKey: chain.nextChainKey, + recvChainKey: chain.nextChainKey, // symmetric start; will separate after first ratchet step + sendIndex: 0, + recvIndex: 0, + theirEphemeralPub: params.theirSignedPreKey, // placeholder + ourEphemeralKeyPair: { pub: ourEphemeral.publicKey, priv: ourEphemeral.privateKey }, + previousCounter: 0, + skippedMessageKeys: {}, + version: 1, + }; + + return { peerId, state: snapshot }; + } + + async encrypt(session: RatchetSession, plaintext: ByteArray, associatedData?: ByteArray): Promise { + const { state } = session; + const { messageKey, nextChainKey } = await this.ctx.crypto.kdf(state.sendChainKey, toBytes('MSG')); // message-level KDF + state.sendChainKey = nextChainKey; + const nonce = await this.ctx.rng.randomBytes(12); + const ciphertext = await this.ctx.crypto.aeadEncrypt(messageKey, nonce, plaintext, associatedData, 'AES-256-GCM'); + + const header: RatchetHeader = { + ephemeralPub: state.ourEphemeralKeyPair!.pub, + previousCounter: state.previousCounter ?? 0, + messageIndex: state.sendIndex, + }; + + const payload: EncryptedPayload = { + version: VERSION, + header, + ciphertext, // assumed to include auth tag (provider decides) + associatedData, + messageId: await this.ctx.rng.uuidV4(), + sentAt: Date.now(), + senderKeyId: toHex(state.ourEphemeralKeyPair!.pub).slice(0, 16), + }; + + state.sendIndex += 1; + return payload; + } + + async decrypt(session: RatchetSession, payload: EncryptedPayload, associatedData?: ByteArray): Promise { + const { state } = session; + const header = payload.header; + + // For brevity skipping full skipped key / header checking logic + const { messageKey, nextChainKey } = await this.ctx.crypto.kdf(state.recvChainKey, toBytes('MSG')); + state.recvChainKey = nextChainKey; + + try { + const plaintext = await this.ctx.crypto.aeadDecrypt( + messageKey, + payload.ciphertext.slice(0, 12), + payload.ciphertext, + associatedData, + 'AES-256-GCM', + ); + state.recvIndex += 1; + return { plaintext, header, messageId: payload.messageId, replay: false }; + } catch (e) { + throw new ProtocolError({ code: 'DECRYPT_FAILED', message: 'Unable to decrypt message', cause: e }); + } + } + + serializeState(session: RatchetSession): RatchetStateSnapshot { + return session.state; // shallow (caller can deep copy if needed) + } + + private async deriveRootKey(input: ByteArray): Promise { + const h = await this.ctx.hash.hash(input, 'SHA-256'); + return h; // simplistic; in production use HKDF chaining + } +} + +function concat(a: ByteArray, b: ByteArray): ByteArray { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +function toBytes(str: string): ByteArray { + return new TextEncoder().encode(str); +} +function toHex(buf: ByteArray): string { + return Array.from(buf) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/packages/e2e-crypto-core/src/core/keyManagement.ts b/packages/e2e-crypto-core/src/core/keyManagement.ts new file mode 100644 index 0000000000000..1adcf2368a636 --- /dev/null +++ b/packages/e2e-crypto-core/src/core/keyManagement.ts @@ -0,0 +1,71 @@ +import type { ProviderContext } from '../providers/interfaces'; +import type { IdentityKeyPair, KeyMaterial, ByteArray } from '../types'; +import { ProtocolError } from '../types'; + +export class KeyManager { + constructor(private readonly ctx: ProviderContext) {} + + async ensureIdentityKey(): Promise { + const existing = await this.ctx.storage.loadIdentityKey(); + if (existing?.publicKey && existing.secretKey) { + return { publicKey: existing.publicKey, privateKey: existing.secretKey, algorithm: existing.algorithm }; + } + const kp = await this.ctx.crypto.generateEd25519KeyPair(); + const material: KeyMaterial = { + id: 'identity', + publicKey: kp.publicKey, + secretKey: kp.privateKey, + algorithm: 'Ed25519', + createdAt: Date.now(), + }; + await this.ctx.storage.storeIdentityKey(material); + return { publicKey: kp.publicKey, privateKey: kp.privateKey, algorithm: 'Ed25519' }; + } + + async signPreKey(preKeyPub: ByteArray, identityPriv: ByteArray): Promise { + return this.ctx.crypto.ed25519Sign(identityPriv, preKeyPub); + } + + async buildPreKeyBundle(): Promise { + const identity = await this.ensureIdentityKey(); + const signedPre = await this.ctx.crypto.generateX25519KeyPair(); + const signature = await this.signPreKey(signedPre.publicKey, identity.privateKey); + return { + identityKey: identity.publicKey, + signedPreKey: signedPre.publicKey, + signedPreKeySignature: signature, + oneTimePreKeys: [], + timestamp: Date.now(), + }; + } +} + +export interface PreKeyBundleOut { + identityKey: ByteArray; + signedPreKey: ByteArray; + signedPreKeySignature: ByteArray; + oneTimePreKeys: ByteArray[]; + timestamp: number; +} + +export class GroupKeyManager { + constructor(private readonly ctx: ProviderContext) {} + + async deriveGroupKey(groupId: string, epoch: number): Promise { + const seed = await this.ctx.hash.hash(new TextEncoder().encode(`${groupId}:${epoch}`), 'SHA-256'); + const derived = await this.ctx.crypto.kdf(seed, new TextEncoder().encode('GROUP')); + return derived.messageKey; // using messageKey part for actual symmetric key + } + + async rotateGroupKey(groupId: string, nextEpoch: number): Promise { + const key = await this.deriveGroupKey(groupId, nextEpoch); + await this.ctx.storage.storeGroupState(groupId, { epoch: nextEpoch, key }); + return key; + } + + async currentGroupKey(groupId: string): Promise<{ epoch: number; key: ByteArray }> { + const state = await this.ctx.storage.loadGroupState<{ epoch: number; key: ByteArray }>(groupId); + if (!state) throw new ProtocolError({ code: 'NO_GROUP_KEY', message: 'Group key missing' }); + return state; + } +} diff --git a/packages/e2e-crypto-core/src/core/messageCodec.ts b/packages/e2e-crypto-core/src/core/messageCodec.ts new file mode 100644 index 0000000000000..780d37bab2af9 --- /dev/null +++ b/packages/e2e-crypto-core/src/core/messageCodec.ts @@ -0,0 +1,68 @@ +import type { EncryptedPayload, GroupEnvelope, ByteArray } from '../types'; + +/* Pure serialization helpers (canonical, stable). Chosen JSON + base64 for simplicity; could be swapped. */ + +export function serializePayload(p: EncryptedPayload): ByteArray { + const json = JSON.stringify({ + v: p.version, + h: { + e: toB64(p.header.ephemeralPub), + pc: p.header.previousCounter, + i: p.header.messageIndex, + }, + c: toB64(p.ciphertext), + a: p.associatedData ? toB64(p.associatedData) : undefined, + mid: p.messageId, + at: p.sentAt, + sid: p.senderKeyId, + }); + return new TextEncoder().encode(json); +} + +export function deserializePayload(data: ByteArray): EncryptedPayload { + const obj = JSON.parse(new TextDecoder().decode(data)); + return { + version: obj.v, + header: { + ephemeralPub: fromB64(obj.h.e), + previousCounter: obj.h.pc, + messageIndex: obj.h.i, + }, + ciphertext: fromB64(obj.c), + associatedData: obj.a ? fromB64(obj.a) : undefined, + messageId: obj.mid, + sentAt: obj.at, + senderKeyId: obj.sid, + }; +} + +export function serializeGroupEnvelope(env: GroupEnvelope): ByteArray { + const json = JSON.stringify({ + g: env.groupId, + u: env.senderUserId, + mv: env.membershipVersion, + p: toB64(serializePayload(env.payload)), + }); + return new TextEncoder().encode(json); +} + +export function deserializeGroupEnvelope(data: ByteArray): GroupEnvelope { + const obj = JSON.parse(new TextDecoder().decode(data)); + return { + groupId: obj.g, + senderUserId: obj.u, + membershipVersion: obj.mv, + payload: deserializePayload(fromB64(obj.p)), + } as GroupEnvelope; +} + +function toB64(b: ByteArray): string { + return btoa(String.fromCharCode(...b)); +} +function fromB64(s: string): ByteArray { + return new Uint8Array( + atob(s) + .split('') + .map((c) => c.charCodeAt(0)), + ); +} diff --git a/packages/e2e-crypto-core/src/core/messageIntegrity.ts b/packages/e2e-crypto-core/src/core/messageIntegrity.ts new file mode 100644 index 0000000000000..267a6f370a3ad --- /dev/null +++ b/packages/e2e-crypto-core/src/core/messageIntegrity.ts @@ -0,0 +1,26 @@ +import type { ByteArray, EncryptedPayload } from '../types'; +import type { ProviderContext } from '../providers/interfaces'; + +export class MessageIntegrity { + constructor(private readonly ctx: ProviderContext) {} + + async computeMAC(payload: EncryptedPayload, macKey: ByteArray): Promise { + // Build canonical associated string + const ad = new TextEncoder().encode( + [payload.messageId, payload.sentAt.toString(), payload.senderKeyId, payload.header.messageIndex.toString()].join('|'), + ); + return this.ctx.hash.hmac(macKey, ad, 'SHA-256'); + } + + async verifyMAC(payload: EncryptedPayload, macKey: ByteArray, mac: ByteArray): Promise { + const expected = await this.computeMAC(payload, macKey); + return timingSafeEqual(expected, mac); + } +} + +function timingSafeEqual(a: ByteArray, b: ByteArray): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} diff --git a/packages/e2e-crypto-core/src/core/replayProtection.ts b/packages/e2e-crypto-core/src/core/replayProtection.ts new file mode 100644 index 0000000000000..f6d3be99fc635 --- /dev/null +++ b/packages/e2e-crypto-core/src/core/replayProtection.ts @@ -0,0 +1,32 @@ +import type { ByteArray } from '../types'; + +/* Stateless Bloom filter style interface would need storage; here we define a simple in-memory helper (sans-IO expects host to persist if desired). */ + +export interface ReplayCacheLike { + has(id: string): boolean; + add(id: string): void; +} + +export class SimpleReplayCache implements ReplayCacheLike { + private readonly window: Map = new Map(); + private readonly ttlMs: number; + constructor(ttlMs = 10 * 60 * 1000) { + this.ttlMs = ttlMs; + } + has(id: string): boolean { + this.sweep(); + return this.window.has(id); + } + add(id: string): void { + this.sweep(); + this.window.set(id, Date.now()); + } + private sweep(): void { + const now = Date.now(); + for (const [k, v] of this.window.entries()) if (now - v > this.ttlMs) this.window.delete(k); + } +} + +export function deriveReplayId(messageId: string, header: { messageIndex: number; previousCounter: number }): string { + return `${messageId}:${header.messageIndex}:${header.previousCounter}`; +} diff --git a/packages/e2e-crypto-core/src/examples/exampleProviders.ts b/packages/e2e-crypto-core/src/examples/exampleProviders.ts new file mode 100644 index 0000000000000..f6cc792ec3707 --- /dev/null +++ b/packages/e2e-crypto-core/src/examples/exampleProviders.ts @@ -0,0 +1,121 @@ +/* Example (non-secure) providers for documentation & testing only. DO NOT use in production. */ +import type { CryptoProvider, RandomnessProvider, HashProvider, KeyStorageProvider } from '../providers/interfaces'; +import type { ByteArray, KeyMaterial } from '../types'; + +export const insecureRng: RandomnessProvider = { + async randomBytes(length) { + return cryptoRandom(length); + }, + async uuidV4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + }, +}; + +export const memStorage = (): KeyStorageProvider => { + const store: Record = {}; + return { + async storeIdentityKey(k: KeyMaterial) { + store['identity'] = k; + }, + async loadIdentityKey() { + return store['identity']; + }, + async storeSessionState(peerId, state) { + store[`sess:${peerId}`] = state; + }, + async loadSessionState(peerId) { + return store[`sess:${peerId}`]; + }, + async storeGroupState(groupId, state) { + store[`group:${groupId}`] = state; + }, + async loadGroupState(groupId) { + return store[`group:${groupId}`]; + }, + }; +}; + +export const naiveHash: HashProvider = { + async hash(data, algorithm) { + return sha(data, algorithm); + }, + async hmac(key, data, algorithm) { + return simpleHmac(key, toBytes(data), algorithm); + }, +}; + +export const dummyCrypto: CryptoProvider = { + async generateX25519KeyPair() { + return keyPair(32); + }, + async generateEd25519KeyPair() { + return keyPair(32); + }, + async generateSymmetricKey(length) { + return cryptoRandom(length); + }, + async diffieHellman(priv, pub) { + return xorBytes(priv, pub); + }, + async ed25519Sign(_priv, msg) { + return sha(msg, 'SHA-256'); + }, + async ed25519Verify(_pub, msg, sig) { + const h = await sha(msg, 'SHA-256'); + return eq(h, sig); + }, + async aeadEncrypt(key, nonce, plaintext, aad) { + return concat(nonce, plaintext, aad || new Uint8Array()); + }, + async aeadDecrypt(_key, nonceAndRest, ciphertext) { + return ciphertext.slice(nonceAndRest.length); + }, + async kdf(chainKey, info) { + return { nextChainKey: await sha(xorBytes(chainKey, info), 'SHA-256'), messageKey: await sha(chainKey, 'SHA-256') }; + }, +}; + +// Helpers (insecure mock implementations) --------------------------------------------------------- +function cryptoRandom(len: number): ByteArray { + const b = new Uint8Array(len); + for (let i = 0; i < len; i++) b[i] = Math.floor(Math.random() * 256); + return b; +} +function keyPair(len: number) { + return { publicKey: cryptoRandom(len), privateKey: cryptoRandom(len) }; +} +function toBytes(x: any): ByteArray { + return typeof x === 'string' ? new TextEncoder().encode(x) : x; +} +async function sha(data: any, _alg: string): Promise { + return toBytes(data).slice(0); +} +async function simpleHmac(key: ByteArray, data: ByteArray, _alg: string): Promise { + return xorBytes(key, data); +} +function xorBytes(a: ByteArray, b: ByteArray): ByteArray { + const len = Math.min(a.length, b.length); + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i]; + return out; +} +function eq(a: ByteArray, b: ByteArray) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} +function concat(...arrays: ByteArray[]): ByteArray { + let total = 0; + arrays.forEach((a) => (total += a.length)); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} diff --git a/packages/e2e-crypto-core/src/index.ts b/packages/e2e-crypto-core/src/index.ts new file mode 100644 index 0000000000000..cf07615113a86 --- /dev/null +++ b/packages/e2e-crypto-core/src/index.ts @@ -0,0 +1,8 @@ +export * from './types'; +export * from './providers/interfaces'; +export * from './core/doubleRatchet'; +export * from './core/keyManagement'; +export * from './core/messageCodec'; +export * from './core/messageIntegrity'; +export * from './core/replayProtection'; +export * from './protocol/session'; diff --git a/packages/e2e-crypto-core/src/protocol/session.ts b/packages/e2e-crypto-core/src/protocol/session.ts new file mode 100644 index 0000000000000..e7f6d048d0b41 --- /dev/null +++ b/packages/e2e-crypto-core/src/protocol/session.ts @@ -0,0 +1,52 @@ +import type { ProviderContext } from '../providers/interfaces'; +import type { ByteArray, EncryptedPayload, DecryptionResult, RatchetStateSnapshot } from '../types'; +import { DoubleRatchetEngine, type RatchetSession, type InitializeSessionParams } from '../core/doubleRatchet'; +import { ProtocolError } from '../types'; +import { SimpleReplayCache, deriveReplayId } from '../core/replayProtection'; + +export interface SessionConfig { + peerId: string; + replayProtection?: boolean; +} + +export class SessionManager { + private readonly ratchet: DoubleRatchetEngine; + private readonly replay?: SimpleReplayCache; + constructor(private readonly ctx: ProviderContext) { + this.ratchet = new DoubleRatchetEngine(ctx); + } + + enableReplayProtection(ttlMs?: number) { + if (!this.replay) (this as any).replay = new SimpleReplayCache(ttlMs); + } + + async createOrLoadPeerSession(peerId: string, init?: InitializeSessionParams): Promise { + const existing = await this.ctx.storage.loadSessionState(peerId); + if (existing) { + return { peerId, state: existing }; + } + if (!init) throw new ProtocolError({ code: 'NO_INIT_PARAMS', message: 'Missing initialization params' }); + const session = await this.ratchet.initialize(peerId, init); + await this.ctx.storage.storeSessionState(peerId, session.state); + return session; + } + + async encrypt(peerId: string, plaintext: ByteArray, ad?: ByteArray): Promise { + const session = await this.createOrLoadPeerSession(peerId); // load existing + const payload = await this.ratchet.encrypt(session, plaintext, ad); + await this.ctx.storage.storeSessionState(peerId, session.state); + return payload; + } + + async decrypt(peerId: string, payload: EncryptedPayload, ad?: ByteArray): Promise { + const session = await this.createOrLoadPeerSession(peerId); // must exist + const replayId = deriveReplayId(payload.messageId, payload.header); + if (this.replay && this.replay.has(replayId)) { + return { plaintext: new Uint8Array(), header: payload.header, messageId: payload.messageId, replay: true }; // caller decides + } + const result = await this.ratchet.decrypt(session, payload, ad); + if (this.replay) this.replay.add(replayId); + await this.ctx.storage.storeSessionState(peerId, session.state); + return result; + } +} diff --git a/packages/e2e-crypto-core/src/providers/interfaces.ts b/packages/e2e-crypto-core/src/providers/interfaces.ts new file mode 100644 index 0000000000000..18c2ca69e9d21 --- /dev/null +++ b/packages/e2e-crypto-core/src/providers/interfaces.ts @@ -0,0 +1,83 @@ +import type { ByteArray, KeyMaterial, KeyDerivationParams } from '../types'; + +/* Sans-IO provider abstractions */ + +export interface RandomnessProvider { + randomBytes(length: number): Promise; + uuidV4(): Promise; // used for message IDs +} + +export interface HashProvider { + hash(data: ByteArray | string, algorithm: 'SHA-256' | 'SHA-512'): Promise; + hmac(key: ByteArray, data: ByteArray | string, algorithm: 'SHA-256' | 'SHA-512'): Promise; + hkdf?(ikm: ByteArray, params: KeyDerivationParams): Promise; // optional specialized + pbkdf2?( + password: ByteArray, + params: KeyDerivationParams & { iterations: number; algorithm: 'PBKDF2-SHA256' | 'PBKDF2-SHA512' }, + ): Promise; +} + +export interface CryptoProvider { + generateX25519KeyPair(): Promise<{ publicKey: ByteArray; privateKey: ByteArray }>; + generateEd25519KeyPair(): Promise<{ publicKey: ByteArray; privateKey: ByteArray }>; + generateSymmetricKey(length: 16 | 32): Promise; // raw bytes (16=128, 32=256) + diffieHellman(privateKey: ByteArray, publicKey: ByteArray): Promise; // X25519 + ed25519Sign(privateKey: ByteArray, message: ByteArray): Promise; + ed25519Verify(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Promise; + aeadEncrypt( + key: ByteArray, + nonce: ByteArray, + plaintext: ByteArray, + aad?: ByteArray, + algorithm?: 'AES-256-GCM' | 'CHACHA20-POLY1305', + ): Promise; + aeadDecrypt( + key: ByteArray, + nonce: ByteArray, + ciphertext: ByteArray, + aad?: ByteArray, + algorithm?: 'AES-256-GCM' | 'CHACHA20-POLY1305', + ): Promise; + kdf(chainKey: ByteArray, info: ByteArray): Promise<{ nextChainKey: ByteArray; messageKey: ByteArray }>; +} + +export interface KeyStorageProvider { + storeIdentityKey(key: KeyMaterial): Promise; + loadIdentityKey(): Promise; + storeSessionState(peerId: string, state: unknown): Promise; + loadSessionState(peerId: string): Promise; + storeGroupState(groupId: string, state: unknown): Promise; + loadGroupState(groupId: string): Promise; + listPeerSessions?(): Promise; // optional introspection +} + +export interface NetworkProvider { + // All methods exchange opaque byte arrays / JSON-serializable records; no transport assumptions + publishPreKeyBundle(bundle: PreKeyBundle): Promise; + fetchPreKeyBundle(peerId: string): Promise; + submitGroupMessage(groupId: string, envelope: GroupNetworkEnvelope): Promise; + fetchGroupMessages(groupId: string, since?: number): Promise; +} + +export interface PreKeyBundle { + identityKey: ByteArray; // Ed25519 public + signedPreKey: ByteArray; // X25519 public (signed) + signedPreKeySignature: ByteArray; // Ed25519 signature + oneTimePreKeys?: ByteArray[]; // optional X25519 + timestamp: number; +} + +export interface GroupNetworkEnvelope { + groupId: string; + senderId: string; + payload: ByteArray; // serialized GroupEnvelope + sentAt: number; +} + +export interface ProviderContext { + crypto: CryptoProvider; + rng: RandomnessProvider; + hash: HashProvider; + storage: KeyStorageProvider; + net?: NetworkProvider; // optional if only local operations +} diff --git a/packages/e2e-crypto-core/src/types.ts b/packages/e2e-crypto-core/src/types.ts new file mode 100644 index 0000000000000..24b82877f1b90 --- /dev/null +++ b/packages/e2e-crypto-core/src/types.ts @@ -0,0 +1,92 @@ +/* Core shared types for the E2E crypto core */ + +export type ByteArray = Uint8Array; // explicit alias for clarity + +export interface KeyMaterial { + id: string; // stable identifier (e.g. KID / hash prefix) + publicKey?: ByteArray; // raw public part when asymmetric + secretKey?: ByteArray; // raw secret/symmetric key bytes (NEVER store here in production key store without encryption) + algorithm: string; // e.g. 'X25519', 'Ed25519', 'AES-256-GCM' + createdAt: number; + expiresAt?: number; + meta?: Record; +} + +export interface RatchetStateSnapshot { + rootKey: ByteArray; + sendChainKey: ByteArray; + recvChainKey: ByteArray; + sendIndex: number; + recvIndex: number; + theirEphemeralPub?: ByteArray; + ourEphemeralKeyPair?: { pub: ByteArray; priv: ByteArray }; + previousCounter?: number; // for skipped message handling + skippedMessageKeys?: Record; // keyId -> key + version: 1; +} + +export interface EncryptedPayload { + version: number; // protocol version + header: RatchetHeader; // ratchet header + ciphertext: ByteArray; // AEAD ciphertext (includes auth tag) + associatedData?: ByteArray; // optional external AD used when encrypting + messageId: string; // unique per message (nonce/uuid) + sentAt: number; // epoch ms + senderKeyId: string; // used for sender identity / verification + digest?: ByteArray; // optional MAC/HMAC if layered +} + +export interface RatchetHeader { + ephemeralPub: ByteArray; // sender's current DH ratchet public key + previousCounter: number; // number of messages in previous sending chain + messageIndex: number; // index within current sending chain +} + +export interface DecryptionResult { + plaintext: ByteArray; + header: RatchetHeader; + messageId: string; + replay: boolean; // true if rejected / duplicate would be flagged upstream +} + +export interface GroupEnvelope { + groupId: string; + senderUserId: string; + payload: EncryptedPayload; + membershipVersion: number; // for group key state consistency +} + +export interface KeyDerivationParams { + salt: ByteArray; + info?: ByteArray; + iterations?: number; + algorithm?: string; // e.g. 'HKDF-SHA256', 'PBKDF2-SHA256' + length: number; // desired bytes +} + +export interface ProtocolErrorDetails { + code: string; + message: string; + cause?: unknown; + context?: Record; +} + +export class ProtocolError extends Error { + readonly code: string; + readonly cause?: unknown; + readonly context?: Record; + constructor(details: ProtocolErrorDetails) { + super(details.message); + this.code = details.code; + this.cause = details.cause; + this.context = details.context; + } +} + +export type IdentityKeyPair = { publicKey: ByteArray; privateKey: ByteArray; algorithm: string }; +export type EphemeralKeyPair = { publicKey: ByteArray; privateKey: ByteArray; algorithm: string }; + +export interface SealedMessage { + envelope: EncryptedPayload | GroupEnvelope; + serialized: ByteArray; // canonical encoding output (provided by higher layer codec) +} diff --git a/packages/e2e-crypto-core/tsconfig.build.json b/packages/e2e-crypto-core/tsconfig.build.json new file mode 100644 index 0000000000000..d379bcec51674 --- /dev/null +++ b/packages/e2e-crypto-core/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig/tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "composite": false, + "strict": true, + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} From 49869a8aab1523bc1270f54447147e514091283c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 12 Aug 2025 14:16:11 -0300 Subject: [PATCH 002/251] storage stub --- apps/meteor/app/e2e/client/helper.ts | 29 +- .../app/e2e/client/rocketchat.e2e.room.ts | 4 +- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 41 ++- .../views/root/MainLayout/LoggedInArea.tsx | 4 +- apps/meteor/package.json | 1 + apps/meteor/tests/e2e/e2e-encryption.spec.ts | 32 +- .../page-objects/fragments/home-content.ts | 8 +- packages/e2e-crypto-core/.gitignore | 1 + packages/e2e-crypto-core/package.json | 10 +- .../e2e-crypto-core/scripts/postbuild.cjs | 19 ++ .../src/__tests__/e2ee.test.ts | 63 ++++ .../e2e-crypto-core/src/core/doubleRatchet.ts | 127 -------- .../e2e-crypto-core/src/core/keyManagement.ts | 71 ----- .../e2e-crypto-core/src/core/messageCodec.ts | 68 ----- .../src/core/messageIntegrity.ts | 26 -- .../src/core/replayProtection.ts | 32 -- .../src/examples/exampleProviders.ts | 121 -------- packages/e2e-crypto-core/src/index.ts | 81 ++++- .../e2e-crypto-core/src/protocol/session.ts | 52 ---- .../src/providers/interfaces.ts | 83 ----- packages/e2e-crypto-core/src/types.ts | 92 ------ .../e2e-crypto-core/src}/wordList.ts | 2 +- packages/e2e-crypto-core/tsconfig.build.json | 14 +- packages/e2e-crypto-core/tsconfig.json | 29 ++ yarn.lock | 284 ++++++++++++++++++ 25 files changed, 523 insertions(+), 771 deletions(-) create mode 100644 packages/e2e-crypto-core/.gitignore create mode 100644 packages/e2e-crypto-core/scripts/postbuild.cjs create mode 100644 packages/e2e-crypto-core/src/__tests__/e2ee.test.ts delete mode 100644 packages/e2e-crypto-core/src/core/doubleRatchet.ts delete mode 100644 packages/e2e-crypto-core/src/core/keyManagement.ts delete mode 100644 packages/e2e-crypto-core/src/core/messageCodec.ts delete mode 100644 packages/e2e-crypto-core/src/core/messageIntegrity.ts delete mode 100644 packages/e2e-crypto-core/src/core/replayProtection.ts delete mode 100644 packages/e2e-crypto-core/src/examples/exampleProviders.ts delete mode 100644 packages/e2e-crypto-core/src/protocol/session.ts delete mode 100644 packages/e2e-crypto-core/src/providers/interfaces.ts delete mode 100644 packages/e2e-crypto-core/src/types.ts rename {apps/meteor/app/e2e/client => packages/e2e-crypto-core/src}/wordList.ts (99%) create mode 100644 packages/e2e-crypto-core/tsconfig.json diff --git a/apps/meteor/app/e2e/client/helper.ts b/apps/meteor/app/e2e/client/helper.ts index 3a57cd789ca69..cf4b395b4f471 100644 --- a/apps/meteor/app/e2e/client/helper.ts +++ b/apps/meteor/app/e2e/client/helper.ts @@ -1,4 +1,3 @@ -import { Random } from '@rocket.chat/random'; import ByteBuffer from 'bytebuffer'; export function toString(thing: any) { @@ -41,23 +40,23 @@ export function splitVectorAndEcryptedData(cipherText: any) { return [vector, encryptedData]; } -export async function encryptRSA(key: any, data: any) { +export async function encryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); } -export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } -export async function encryptAESCTR(vector: any, key: any, data: any) { - return crypto.subtle.encrypt({ name: 'AES-CTR', counter: vector, length: 64 }, key, data); +export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { + return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } export async function decryptRSA(key: CryptoKey, data: Uint8Array) { return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } -export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); } @@ -82,7 +81,7 @@ export async function generateRSAKey() { ); } -export async function exportJWKKey(key: any) { +export async function exportJWKKey(key: CryptoKey): Promise { return crypto.subtle.exportKey('jwk', key); } @@ -105,7 +104,7 @@ export async function importAESKey(keyData: any, keyUsages: ReadonlyArray = ['deriveKey']) { +export async function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArray = ['deriveKey']) { return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); } @@ -129,20 +128,6 @@ export async function readFileAsArrayBuffer(file: File) { }); } -export async function generateMnemonicPhrase(n: any, sep = ' ') { - const { default: wordList } = await import('./wordList'); - const result = new Array(n); - let len = wordList.length; - const taken = new Array(len); - - while (n--) { - const x = Math.floor(Random.fraction() * len); - result[n] = wordList[x in taken ? taken[x] : x]; - taken[x] = --len in taken ? taken[len] : len; - } - return result.join(sep); -} - export async function createSha256HashFromText(data: any) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hash)) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts index ab66b47d645b1..f5b0815a6da20 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.ts @@ -603,7 +603,7 @@ export class E2ERoom extends Emitter { } // Encrypts messages - async encryptText(data: Uint8Array) { + async encryptText(data: Uint8Array) { const vector = crypto.getRandomValues(new Uint8Array(16)); try { @@ -701,7 +701,7 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { + async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { const result = await decryptAES(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 9062e7aba50c2..eeb1dbd6cf13c 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -3,6 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; +import { E2EE, type KeyPair } from '@rocket.chat/e2e-crypto-core'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -24,7 +25,6 @@ import { importRSAKey, importRawKey, deriveKey, - generateMnemonicPhrase, } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -48,11 +48,6 @@ import './events'; let failedToDecodeKey = false; -type KeyPair = { - public_key: string | null; - private_key: string | null; -}; - const ROOM_KEY_EXCHANGE_SIZE = 10; const E2EEStateDependency = new Tracker.Dependency(); @@ -73,11 +68,14 @@ class E2E extends Emitter { private state: E2EEState; + private e2ee: E2EE; + constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; + this.e2ee = E2EE.fromWeb(Accounts.storageLocation, crypto); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); @@ -207,8 +205,8 @@ class E2E extends Emitter { }); } - shouldAskForE2EEPassword() { - const { private_key } = this.getKeysFromLocalStorage(); + async shouldAskForE2EEPassword() { + const { private_key } = await this.getKeysFromLocalStorage(); return this.db_private_key && !private_key; } @@ -333,11 +331,8 @@ class E2E extends Emitter { }); } - getKeysFromLocalStorage(): KeyPair { - return { - public_key: Accounts.storageLocation.getItem('public_key'), - private_key: Accounts.storageLocation.getItem('private_key'), - }; + getKeysFromLocalStorage(): Promise { + return this.e2ee.getKeysFromLocalStorage(); } initiateHandshake() { @@ -355,7 +350,7 @@ class E2E extends Emitter { imperativeModal.close(); }, onConfirm: () => { - Accounts.storageLocation.removeItem('e2e.randomPassword'); + void this.e2ee.removeRandomPassword(); this.setState(E2EEState.READY); dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); this.closeAlert(); @@ -374,7 +369,7 @@ class E2E extends Emitter { this.started = true; - let { public_key, private_key } = this.getKeysFromLocalStorage(); + let { public_key, private_key } = await this.getKeysFromLocalStorage(); await this.loadKeysFromDB(); @@ -382,7 +377,7 @@ class E2E extends Emitter { public_key = this.db_public_key; } - if (this.shouldAskForE2EEPassword()) { + if (await this.shouldAskForE2EEPassword()) { try { this.setState(E2EEState.ENTER_PASSWORD); private_key = await this.decodePrivateKey(this.db_private_key as string); @@ -414,10 +409,10 @@ class E2E extends Emitter { if (!this.db_public_key || !this.db_private_key) { this.setState(E2EEState.LOADING_KEYS); - await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); + await this.persistKeys(await this.getKeysFromLocalStorage(), await this.createRandomPassword()); } - const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); + const randomPassword = await this.e2ee.getRandomPassword(); if (randomPassword) { this.setState(E2EEState.SAVE_PASSWORD); this.openAlert({ @@ -447,10 +442,10 @@ class E2E extends Emitter { } async changePassword(newPassword: string): Promise { - await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true }); + await this.persistKeys(await this.getKeysFromLocalStorage(), newPassword, { force: true }); - if (Accounts.storageLocation.getItem('e2e.randomPassword')) { - Accounts.storageLocation.setItem('e2e.randomPassword', newPassword); + if (await this.e2ee.getRandomPassword()) { + await this.e2ee.storeRandomPassword(newPassword); } } @@ -523,9 +518,7 @@ class E2E extends Emitter { } async createRandomPassword(): Promise { - const randomPassword = await generateMnemonicPhrase(5); - Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); - return randomPassword; + return this.e2ee.createRandomPassword(); } async encodePrivateKey(privateKey: string, password: string): Promise { diff --git a/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx b/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx index 64519d88776a6..27e8cd343119a 100644 --- a/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx +++ b/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react'; import { useCustomEmoji } from '../../../hooks/customEmoji/useCustomEmoji'; import { useNotificationUserCalendar } from '../../../hooks/notification/useNotificationUserCalendar'; import { useNotifyUser } from '../../../hooks/notification/useNotifyUser'; -import { useFingerprintChange } from '../../../hooks/useFingerprintChange'; +// import { useFingerprintChange } from '../../../hooks/useFingerprintChange'; import { useFontStylePreference } from '../../../hooks/useFontStylePreference'; import { useRestrictedRoles } from '../../../hooks/useRestrictedRoles'; import { useRootUrlChange } from '../../../hooks/useRootUrlChange'; @@ -37,7 +37,7 @@ const LoggedInArea = ({ children }: { children: ReactNode }) => { useRestrictedRoles(); // These 3 hooks below need to be called in this order due to the way our `setModal` works. // TODO: reevaluate `useSetModal` - useFingerprintChange(); + // useFingerprintChange(); useRootUrlChange(); useTwoFactorAuthSetupCheck(); // diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 92273a2b96a91..5cf01dce81f0a 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -249,6 +249,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", + "@rocket.chat/e2e-crypto-core": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index c7aac0696a2a9..3d94e212b875b 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -316,7 +316,7 @@ test.describe('basic features', () => { }); }); -test.describe.serial('e2e-encryption', () => { +test.describe('e2e-encryption', () => { let poHomeChannel: HomeChannel; test.use({ storageState: Users.userE2EE.state }); @@ -472,11 +472,11 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('hello @user1'); - const userMention = await page.getByRole('button', { - name: 'user1', - }); - - await expect(userMention).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'user1', + }), + ).toBeVisible(); }); test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { @@ -490,7 +490,7 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel?'); - const channelMention = await page.getByRole('button', { + const channelMention = page.getByRole('button', { name: 'general', }); @@ -512,16 +512,8 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); - const channelMention = await page.getByRole('button', { - name: 'general', - }); - - const userMention = await page.getByRole('button', { - name: 'user1', - }); - - await expect(userMention).toBeVisible(); - await expect(channelMention).toBeVisible(); + await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); }); test('should encrypted field be available on edit room', async ({ page }) => { @@ -544,7 +536,11 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); }); - test('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page }) => { + test('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { + // delete direct message if it exists + await api.post('/im.delete', { username: 'user2' }); + await page.reload(); + await poHomeChannel.sidenav.openNewByLabel('Direct message'); await poHomeChannel.sidenav.inputDirectUsername.click(); await page.keyboard.type('user2'); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index cae0a54e89c55..c3d61a3fe0e91 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -379,7 +379,7 @@ export class HomeContent { } async dragAndDropTxtFile(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/any_file.txt', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -395,7 +395,7 @@ export class HomeContent { } async dragAndDropLstFile(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); + const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'lst-test.lst', { @@ -411,7 +411,7 @@ export class HomeContent { } async dragAndDropTxtFileToThread(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/any_file.txt', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -427,7 +427,7 @@ export class HomeContent { } async sendFileMessage(fileName: string): Promise { - await this.page.locator('input[type=file]').setInputFiles(`./tests/e2e/fixtures/files/${fileName}`); + await this.page.locator('input[type=file]').setInputFiles(`./apps/meteor/tests/e2e/fixtures/files/${fileName}`); } async openLastMessageMenu(): Promise { diff --git a/packages/e2e-crypto-core/.gitignore b/packages/e2e-crypto-core/.gitignore new file mode 100644 index 0000000000000..c33dedd99a2cc --- /dev/null +++ b/packages/e2e-crypto-core/.gitignore @@ -0,0 +1 @@ +dist-cjs \ No newline at end of file diff --git a/packages/e2e-crypto-core/package.json b/packages/e2e-crypto-core/package.json index 669d2b7ccad1d..9065d1473743c 100644 --- a/packages/e2e-crypto-core/package.json +++ b/packages/e2e-crypto-core/package.json @@ -17,17 +17,15 @@ "scripts": { "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.json --module commonjs --outDir dist-cjs && node ./scripts/postbuild.cjs", "clean": "rimraf dist dist-cjs", - "typecheck": "tsc -p tsconfig.build.json --noEmit", + "typecheck": "tsc -p tsconfig.json", "lint": "eslint 'src/**/*.ts'", - "test": "vitest run" + "testunit": "node --experimental-strip-types --test" }, "devDependencies": { - "typescript": "~5.8.3", + "@types/node": "^24.2.1", "rimraf": "^5.0.5", - "eslint": "^9.10.0", - "vitest": "^2.0.0" + "typescript": "~5.9.2" }, - "peerDependencies": {}, "license": "MIT", "publishConfig": { "access": "public" diff --git a/packages/e2e-crypto-core/scripts/postbuild.cjs b/packages/e2e-crypto-core/scripts/postbuild.cjs new file mode 100644 index 0000000000000..182bde160536b --- /dev/null +++ b/packages/e2e-crypto-core/scripts/postbuild.cjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/* Simple postbuild script: merge CJS artifacts into dist for dual package export mapping. + For now we just rename dist-cjs/index.js -> dist/index.cjs (already configured) and copy d.ts if needed. */ + +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const cjsDir = path.join(root, 'dist-cjs'); +const distDir = path.join(root, 'dist'); + +if (!fs.existsSync(cjsDir)) process.exit(0); + +const sourceIndex = path.join(cjsDir, 'index.js'); +const targetIndex = path.join(distDir, 'index.cjs'); +if (fs.existsSync(sourceIndex)) { + fs.copyFileSync(sourceIndex, targetIndex); + console.log('[postbuild] Wrote', path.relative(root, targetIndex)); +} diff --git a/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts b/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts new file mode 100644 index 0000000000000..1269c2810542f --- /dev/null +++ b/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { E2EE } from '../index.ts'; + +class MemoryStorage implements Storage { + private data = new Map(); + clear(): void { + this.data.clear(); + } + getItem(key: string): string | null { + return this.data.has(key) ? this.data.get(key)! : null; + } + key(index: number): string | null { + return Array.from(this.data.keys())[index] ?? null; + } + removeItem(key: string): void { + this.data.delete(key); + } + setItem(key: string, value: string): void { + this.data.set(key, value); + } + get length() { + return this.data.size; + } +} + +class DeterministicCrypto implements Crypto { + private seq: number[]; + private idx = 0; + constructor(seq: number[]) { + this.seq = seq; + } + getRandomValues(array: T): T { + // naive: fill Uint32 or Uint8 + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; + } else if (array instanceof Uint8Array) { + for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; + } + return array; + } + // Unused methods (stubs) + get [Symbol.toStringTag]() { + return 'DeterministicCrypto'; + } + randomUUID(): `${string}-${string}-${string}-${string}-${string}` { + return '00000000-0000-4000-8000-000000000000'; + } + // @ts-expect-error not implemented + subtle: SubtleCrypto; +} + +test('E2EE createRandomPassword deterministic generation with 5 words', async () => { + const storage = new MemoryStorage(); + // Inject custom word list by temporarily defining dynamic import. + // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. + // So we skip testing exact phrase (depends on wordList) and just assert shape. + const crypto = new DeterministicCrypto([1, 2, 3, 4, 5]); + const e2ee = E2EE.fromWeb(storage, crypto as any); + const pwd = await e2ee.createRandomPassword(); + assert.equal(pwd.split(' ').length, 5); + assert.equal(await e2ee.getRandomPassword(), pwd); +}); diff --git a/packages/e2e-crypto-core/src/core/doubleRatchet.ts b/packages/e2e-crypto-core/src/core/doubleRatchet.ts deleted file mode 100644 index 8b0dc5ff0cb92..0000000000000 --- a/packages/e2e-crypto-core/src/core/doubleRatchet.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { ByteArray, RatchetStateSnapshot, EncryptedPayload, RatchetHeader, DecryptionResult } from '../types'; -import type { ProviderContext } from '../providers/interfaces'; -import { ProtocolError } from '../types'; - -/* Minimal Double Ratchet skeleton (sans-IO). Focus: state transitions & KDF chaining. */ - -export interface InitializeSessionParams { - theirIdentityKey: ByteArray; - theirSignedPreKey: ByteArray; - theirOneTimePreKey?: ByteArray; - isInitiator: boolean; -} - -export interface RatchetSession { - peerId: string; // application-level peer identifier - state: RatchetStateSnapshot; -} - -const VERSION = 1; - -export class DoubleRatchetEngine { - constructor(private readonly ctx: ProviderContext) {} - - async initialize(peerId: string, params: InitializeSessionParams): Promise { - // Generate our initial ephemeral - const ourEphemeral = await this.ctx.crypto.generateX25519KeyPair(); - // Perform initial DHs (IK + SPK [+ OPK]) simplified to one combined secret for brevity - const dh1 = await this.ctx.crypto.diffieHellman(ourEphemeral.privateKey, params.theirSignedPreKey); - let combined = dh1; - if (params.theirOneTimePreKey) { - const dh2 = await this.ctx.crypto.diffieHellman(ourEphemeral.privateKey, params.theirOneTimePreKey); - combined = concat(combined, dh2); - } - const rootKey = await this.deriveRootKey(combined); - const chain = await this.ctx.crypto.kdf(rootKey, toBytes('INIT')); - - const snapshot: RatchetStateSnapshot = { - rootKey: rootKey, - sendChainKey: chain.nextChainKey, - recvChainKey: chain.nextChainKey, // symmetric start; will separate after first ratchet step - sendIndex: 0, - recvIndex: 0, - theirEphemeralPub: params.theirSignedPreKey, // placeholder - ourEphemeralKeyPair: { pub: ourEphemeral.publicKey, priv: ourEphemeral.privateKey }, - previousCounter: 0, - skippedMessageKeys: {}, - version: 1, - }; - - return { peerId, state: snapshot }; - } - - async encrypt(session: RatchetSession, plaintext: ByteArray, associatedData?: ByteArray): Promise { - const { state } = session; - const { messageKey, nextChainKey } = await this.ctx.crypto.kdf(state.sendChainKey, toBytes('MSG')); // message-level KDF - state.sendChainKey = nextChainKey; - const nonce = await this.ctx.rng.randomBytes(12); - const ciphertext = await this.ctx.crypto.aeadEncrypt(messageKey, nonce, plaintext, associatedData, 'AES-256-GCM'); - - const header: RatchetHeader = { - ephemeralPub: state.ourEphemeralKeyPair!.pub, - previousCounter: state.previousCounter ?? 0, - messageIndex: state.sendIndex, - }; - - const payload: EncryptedPayload = { - version: VERSION, - header, - ciphertext, // assumed to include auth tag (provider decides) - associatedData, - messageId: await this.ctx.rng.uuidV4(), - sentAt: Date.now(), - senderKeyId: toHex(state.ourEphemeralKeyPair!.pub).slice(0, 16), - }; - - state.sendIndex += 1; - return payload; - } - - async decrypt(session: RatchetSession, payload: EncryptedPayload, associatedData?: ByteArray): Promise { - const { state } = session; - const header = payload.header; - - // For brevity skipping full skipped key / header checking logic - const { messageKey, nextChainKey } = await this.ctx.crypto.kdf(state.recvChainKey, toBytes('MSG')); - state.recvChainKey = nextChainKey; - - try { - const plaintext = await this.ctx.crypto.aeadDecrypt( - messageKey, - payload.ciphertext.slice(0, 12), - payload.ciphertext, - associatedData, - 'AES-256-GCM', - ); - state.recvIndex += 1; - return { plaintext, header, messageId: payload.messageId, replay: false }; - } catch (e) { - throw new ProtocolError({ code: 'DECRYPT_FAILED', message: 'Unable to decrypt message', cause: e }); - } - } - - serializeState(session: RatchetSession): RatchetStateSnapshot { - return session.state; // shallow (caller can deep copy if needed) - } - - private async deriveRootKey(input: ByteArray): Promise { - const h = await this.ctx.hash.hash(input, 'SHA-256'); - return h; // simplistic; in production use HKDF chaining - } -} - -function concat(a: ByteArray, b: ByteArray): ByteArray { - const out = new Uint8Array(a.length + b.length); - out.set(a, 0); - out.set(b, a.length); - return out; -} - -function toBytes(str: string): ByteArray { - return new TextEncoder().encode(str); -} -function toHex(buf: ByteArray): string { - return Array.from(buf) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} diff --git a/packages/e2e-crypto-core/src/core/keyManagement.ts b/packages/e2e-crypto-core/src/core/keyManagement.ts deleted file mode 100644 index 1adcf2368a636..0000000000000 --- a/packages/e2e-crypto-core/src/core/keyManagement.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ProviderContext } from '../providers/interfaces'; -import type { IdentityKeyPair, KeyMaterial, ByteArray } from '../types'; -import { ProtocolError } from '../types'; - -export class KeyManager { - constructor(private readonly ctx: ProviderContext) {} - - async ensureIdentityKey(): Promise { - const existing = await this.ctx.storage.loadIdentityKey(); - if (existing?.publicKey && existing.secretKey) { - return { publicKey: existing.publicKey, privateKey: existing.secretKey, algorithm: existing.algorithm }; - } - const kp = await this.ctx.crypto.generateEd25519KeyPair(); - const material: KeyMaterial = { - id: 'identity', - publicKey: kp.publicKey, - secretKey: kp.privateKey, - algorithm: 'Ed25519', - createdAt: Date.now(), - }; - await this.ctx.storage.storeIdentityKey(material); - return { publicKey: kp.publicKey, privateKey: kp.privateKey, algorithm: 'Ed25519' }; - } - - async signPreKey(preKeyPub: ByteArray, identityPriv: ByteArray): Promise { - return this.ctx.crypto.ed25519Sign(identityPriv, preKeyPub); - } - - async buildPreKeyBundle(): Promise { - const identity = await this.ensureIdentityKey(); - const signedPre = await this.ctx.crypto.generateX25519KeyPair(); - const signature = await this.signPreKey(signedPre.publicKey, identity.privateKey); - return { - identityKey: identity.publicKey, - signedPreKey: signedPre.publicKey, - signedPreKeySignature: signature, - oneTimePreKeys: [], - timestamp: Date.now(), - }; - } -} - -export interface PreKeyBundleOut { - identityKey: ByteArray; - signedPreKey: ByteArray; - signedPreKeySignature: ByteArray; - oneTimePreKeys: ByteArray[]; - timestamp: number; -} - -export class GroupKeyManager { - constructor(private readonly ctx: ProviderContext) {} - - async deriveGroupKey(groupId: string, epoch: number): Promise { - const seed = await this.ctx.hash.hash(new TextEncoder().encode(`${groupId}:${epoch}`), 'SHA-256'); - const derived = await this.ctx.crypto.kdf(seed, new TextEncoder().encode('GROUP')); - return derived.messageKey; // using messageKey part for actual symmetric key - } - - async rotateGroupKey(groupId: string, nextEpoch: number): Promise { - const key = await this.deriveGroupKey(groupId, nextEpoch); - await this.ctx.storage.storeGroupState(groupId, { epoch: nextEpoch, key }); - return key; - } - - async currentGroupKey(groupId: string): Promise<{ epoch: number; key: ByteArray }> { - const state = await this.ctx.storage.loadGroupState<{ epoch: number; key: ByteArray }>(groupId); - if (!state) throw new ProtocolError({ code: 'NO_GROUP_KEY', message: 'Group key missing' }); - return state; - } -} diff --git a/packages/e2e-crypto-core/src/core/messageCodec.ts b/packages/e2e-crypto-core/src/core/messageCodec.ts deleted file mode 100644 index 780d37bab2af9..0000000000000 --- a/packages/e2e-crypto-core/src/core/messageCodec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { EncryptedPayload, GroupEnvelope, ByteArray } from '../types'; - -/* Pure serialization helpers (canonical, stable). Chosen JSON + base64 for simplicity; could be swapped. */ - -export function serializePayload(p: EncryptedPayload): ByteArray { - const json = JSON.stringify({ - v: p.version, - h: { - e: toB64(p.header.ephemeralPub), - pc: p.header.previousCounter, - i: p.header.messageIndex, - }, - c: toB64(p.ciphertext), - a: p.associatedData ? toB64(p.associatedData) : undefined, - mid: p.messageId, - at: p.sentAt, - sid: p.senderKeyId, - }); - return new TextEncoder().encode(json); -} - -export function deserializePayload(data: ByteArray): EncryptedPayload { - const obj = JSON.parse(new TextDecoder().decode(data)); - return { - version: obj.v, - header: { - ephemeralPub: fromB64(obj.h.e), - previousCounter: obj.h.pc, - messageIndex: obj.h.i, - }, - ciphertext: fromB64(obj.c), - associatedData: obj.a ? fromB64(obj.a) : undefined, - messageId: obj.mid, - sentAt: obj.at, - senderKeyId: obj.sid, - }; -} - -export function serializeGroupEnvelope(env: GroupEnvelope): ByteArray { - const json = JSON.stringify({ - g: env.groupId, - u: env.senderUserId, - mv: env.membershipVersion, - p: toB64(serializePayload(env.payload)), - }); - return new TextEncoder().encode(json); -} - -export function deserializeGroupEnvelope(data: ByteArray): GroupEnvelope { - const obj = JSON.parse(new TextDecoder().decode(data)); - return { - groupId: obj.g, - senderUserId: obj.u, - membershipVersion: obj.mv, - payload: deserializePayload(fromB64(obj.p)), - } as GroupEnvelope; -} - -function toB64(b: ByteArray): string { - return btoa(String.fromCharCode(...b)); -} -function fromB64(s: string): ByteArray { - return new Uint8Array( - atob(s) - .split('') - .map((c) => c.charCodeAt(0)), - ); -} diff --git a/packages/e2e-crypto-core/src/core/messageIntegrity.ts b/packages/e2e-crypto-core/src/core/messageIntegrity.ts deleted file mode 100644 index 267a6f370a3ad..0000000000000 --- a/packages/e2e-crypto-core/src/core/messageIntegrity.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ByteArray, EncryptedPayload } from '../types'; -import type { ProviderContext } from '../providers/interfaces'; - -export class MessageIntegrity { - constructor(private readonly ctx: ProviderContext) {} - - async computeMAC(payload: EncryptedPayload, macKey: ByteArray): Promise { - // Build canonical associated string - const ad = new TextEncoder().encode( - [payload.messageId, payload.sentAt.toString(), payload.senderKeyId, payload.header.messageIndex.toString()].join('|'), - ); - return this.ctx.hash.hmac(macKey, ad, 'SHA-256'); - } - - async verifyMAC(payload: EncryptedPayload, macKey: ByteArray, mac: ByteArray): Promise { - const expected = await this.computeMAC(payload, macKey); - return timingSafeEqual(expected, mac); - } -} - -function timingSafeEqual(a: ByteArray, b: ByteArray): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; - return diff === 0; -} diff --git a/packages/e2e-crypto-core/src/core/replayProtection.ts b/packages/e2e-crypto-core/src/core/replayProtection.ts deleted file mode 100644 index f6d3be99fc635..0000000000000 --- a/packages/e2e-crypto-core/src/core/replayProtection.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ByteArray } from '../types'; - -/* Stateless Bloom filter style interface would need storage; here we define a simple in-memory helper (sans-IO expects host to persist if desired). */ - -export interface ReplayCacheLike { - has(id: string): boolean; - add(id: string): void; -} - -export class SimpleReplayCache implements ReplayCacheLike { - private readonly window: Map = new Map(); - private readonly ttlMs: number; - constructor(ttlMs = 10 * 60 * 1000) { - this.ttlMs = ttlMs; - } - has(id: string): boolean { - this.sweep(); - return this.window.has(id); - } - add(id: string): void { - this.sweep(); - this.window.set(id, Date.now()); - } - private sweep(): void { - const now = Date.now(); - for (const [k, v] of this.window.entries()) if (now - v > this.ttlMs) this.window.delete(k); - } -} - -export function deriveReplayId(messageId: string, header: { messageIndex: number; previousCounter: number }): string { - return `${messageId}:${header.messageIndex}:${header.previousCounter}`; -} diff --git a/packages/e2e-crypto-core/src/examples/exampleProviders.ts b/packages/e2e-crypto-core/src/examples/exampleProviders.ts deleted file mode 100644 index f6cc792ec3707..0000000000000 --- a/packages/e2e-crypto-core/src/examples/exampleProviders.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* Example (non-secure) providers for documentation & testing only. DO NOT use in production. */ -import type { CryptoProvider, RandomnessProvider, HashProvider, KeyStorageProvider } from '../providers/interfaces'; -import type { ByteArray, KeyMaterial } from '../types'; - -export const insecureRng: RandomnessProvider = { - async randomBytes(length) { - return cryptoRandom(length); - }, - async uuidV4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - }, -}; - -export const memStorage = (): KeyStorageProvider => { - const store: Record = {}; - return { - async storeIdentityKey(k: KeyMaterial) { - store['identity'] = k; - }, - async loadIdentityKey() { - return store['identity']; - }, - async storeSessionState(peerId, state) { - store[`sess:${peerId}`] = state; - }, - async loadSessionState(peerId) { - return store[`sess:${peerId}`]; - }, - async storeGroupState(groupId, state) { - store[`group:${groupId}`] = state; - }, - async loadGroupState(groupId) { - return store[`group:${groupId}`]; - }, - }; -}; - -export const naiveHash: HashProvider = { - async hash(data, algorithm) { - return sha(data, algorithm); - }, - async hmac(key, data, algorithm) { - return simpleHmac(key, toBytes(data), algorithm); - }, -}; - -export const dummyCrypto: CryptoProvider = { - async generateX25519KeyPair() { - return keyPair(32); - }, - async generateEd25519KeyPair() { - return keyPair(32); - }, - async generateSymmetricKey(length) { - return cryptoRandom(length); - }, - async diffieHellman(priv, pub) { - return xorBytes(priv, pub); - }, - async ed25519Sign(_priv, msg) { - return sha(msg, 'SHA-256'); - }, - async ed25519Verify(_pub, msg, sig) { - const h = await sha(msg, 'SHA-256'); - return eq(h, sig); - }, - async aeadEncrypt(key, nonce, plaintext, aad) { - return concat(nonce, plaintext, aad || new Uint8Array()); - }, - async aeadDecrypt(_key, nonceAndRest, ciphertext) { - return ciphertext.slice(nonceAndRest.length); - }, - async kdf(chainKey, info) { - return { nextChainKey: await sha(xorBytes(chainKey, info), 'SHA-256'), messageKey: await sha(chainKey, 'SHA-256') }; - }, -}; - -// Helpers (insecure mock implementations) --------------------------------------------------------- -function cryptoRandom(len: number): ByteArray { - const b = new Uint8Array(len); - for (let i = 0; i < len; i++) b[i] = Math.floor(Math.random() * 256); - return b; -} -function keyPair(len: number) { - return { publicKey: cryptoRandom(len), privateKey: cryptoRandom(len) }; -} -function toBytes(x: any): ByteArray { - return typeof x === 'string' ? new TextEncoder().encode(x) : x; -} -async function sha(data: any, _alg: string): Promise { - return toBytes(data).slice(0); -} -async function simpleHmac(key: ByteArray, data: ByteArray, _alg: string): Promise { - return xorBytes(key, data); -} -function xorBytes(a: ByteArray, b: ByteArray): ByteArray { - const len = Math.min(a.length, b.length); - const out = new Uint8Array(len); - for (let i = 0; i < len; i++) out[i] = a[i] ^ b[i]; - return out; -} -function eq(a: ByteArray, b: ByteArray) { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; -} -function concat(...arrays: ByteArray[]): ByteArray { - let total = 0; - arrays.forEach((a) => (total += a.length)); - const out = new Uint8Array(total); - let off = 0; - for (const a of arrays) { - out.set(a, off); - off += a.length; - } - return out; -} diff --git a/packages/e2e-crypto-core/src/index.ts b/packages/e2e-crypto-core/src/index.ts index cf07615113a86..f1ccfd2a15046 100644 --- a/packages/e2e-crypto-core/src/index.ts +++ b/packages/e2e-crypto-core/src/index.ts @@ -1,8 +1,73 @@ -export * from './types'; -export * from './providers/interfaces'; -export * from './core/doubleRatchet'; -export * from './core/keyManagement'; -export * from './core/messageCodec'; -export * from './core/messageIntegrity'; -export * from './core/replayProtection'; -export * from './protocol/session'; +export interface KeyStorage { + load(keyName: string): Promise; + store(keyName: string, value: string): Promise; + remove(keyName: string): Promise; +} + +export interface RandomGenerator { + getRandomValues(array: T): T; +} + +export type KeyPair = { + public_key: string | null; + private_key: string | null; +}; + +export class E2EE { + #storage: KeyStorage; + #random: RandomGenerator; + + constructor(storage: KeyStorage, random: RandomGenerator) { + this.#storage = storage; + this.#random = random; + } + + static fromWeb(storage: Storage, crypto: Crypto): E2EE { + return new E2EE( + { + load: (keyName) => Promise.resolve(storage.getItem(keyName)), + store: (keyName, value) => Promise.resolve(storage.setItem(keyName, value)), + remove: (keyName) => Promise.resolve(storage.removeItem(keyName)), + }, + { + getRandomValues: (array) => crypto.getRandomValues(array), + }, + ); + } + + async getKeysFromLocalStorage(): Promise { + return { + public_key: await this.#storage.load('public_key'), + private_key: await this.#storage.load('private_key'), + }; + } + + async #generateMnemonicPhrase(n: number, sep = ' '): Promise { + const wordList = await import('./wordList.ts'); + const result = new Array(n); + const randomBuffer = new Uint32Array(n); + this.#random.getRandomValues(randomBuffer); + for (let i = 0; i < n; i++) { + result[i] = wordList.v1[randomBuffer[i]! % wordList.v1.length]!; + } + return result.join(sep); + } + + async storeRandomPassword(randomPassword: string): Promise { + await this.#storage.store('e2e.random_password', randomPassword); + } + + async getRandomPassword(): Promise { + return this.#storage.load('e2e.random_password'); + } + + async removeRandomPassword(): Promise { + await this.#storage.remove('e2e.random_password'); + } + + async createRandomPassword(): Promise { + const randomPassword = await this.#generateMnemonicPhrase(5); + this.storeRandomPassword(randomPassword); + return randomPassword; + } +} diff --git a/packages/e2e-crypto-core/src/protocol/session.ts b/packages/e2e-crypto-core/src/protocol/session.ts deleted file mode 100644 index e7f6d048d0b41..0000000000000 --- a/packages/e2e-crypto-core/src/protocol/session.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ProviderContext } from '../providers/interfaces'; -import type { ByteArray, EncryptedPayload, DecryptionResult, RatchetStateSnapshot } from '../types'; -import { DoubleRatchetEngine, type RatchetSession, type InitializeSessionParams } from '../core/doubleRatchet'; -import { ProtocolError } from '../types'; -import { SimpleReplayCache, deriveReplayId } from '../core/replayProtection'; - -export interface SessionConfig { - peerId: string; - replayProtection?: boolean; -} - -export class SessionManager { - private readonly ratchet: DoubleRatchetEngine; - private readonly replay?: SimpleReplayCache; - constructor(private readonly ctx: ProviderContext) { - this.ratchet = new DoubleRatchetEngine(ctx); - } - - enableReplayProtection(ttlMs?: number) { - if (!this.replay) (this as any).replay = new SimpleReplayCache(ttlMs); - } - - async createOrLoadPeerSession(peerId: string, init?: InitializeSessionParams): Promise { - const existing = await this.ctx.storage.loadSessionState(peerId); - if (existing) { - return { peerId, state: existing }; - } - if (!init) throw new ProtocolError({ code: 'NO_INIT_PARAMS', message: 'Missing initialization params' }); - const session = await this.ratchet.initialize(peerId, init); - await this.ctx.storage.storeSessionState(peerId, session.state); - return session; - } - - async encrypt(peerId: string, plaintext: ByteArray, ad?: ByteArray): Promise { - const session = await this.createOrLoadPeerSession(peerId); // load existing - const payload = await this.ratchet.encrypt(session, plaintext, ad); - await this.ctx.storage.storeSessionState(peerId, session.state); - return payload; - } - - async decrypt(peerId: string, payload: EncryptedPayload, ad?: ByteArray): Promise { - const session = await this.createOrLoadPeerSession(peerId); // must exist - const replayId = deriveReplayId(payload.messageId, payload.header); - if (this.replay && this.replay.has(replayId)) { - return { plaintext: new Uint8Array(), header: payload.header, messageId: payload.messageId, replay: true }; // caller decides - } - const result = await this.ratchet.decrypt(session, payload, ad); - if (this.replay) this.replay.add(replayId); - await this.ctx.storage.storeSessionState(peerId, session.state); - return result; - } -} diff --git a/packages/e2e-crypto-core/src/providers/interfaces.ts b/packages/e2e-crypto-core/src/providers/interfaces.ts deleted file mode 100644 index 18c2ca69e9d21..0000000000000 --- a/packages/e2e-crypto-core/src/providers/interfaces.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { ByteArray, KeyMaterial, KeyDerivationParams } from '../types'; - -/* Sans-IO provider abstractions */ - -export interface RandomnessProvider { - randomBytes(length: number): Promise; - uuidV4(): Promise; // used for message IDs -} - -export interface HashProvider { - hash(data: ByteArray | string, algorithm: 'SHA-256' | 'SHA-512'): Promise; - hmac(key: ByteArray, data: ByteArray | string, algorithm: 'SHA-256' | 'SHA-512'): Promise; - hkdf?(ikm: ByteArray, params: KeyDerivationParams): Promise; // optional specialized - pbkdf2?( - password: ByteArray, - params: KeyDerivationParams & { iterations: number; algorithm: 'PBKDF2-SHA256' | 'PBKDF2-SHA512' }, - ): Promise; -} - -export interface CryptoProvider { - generateX25519KeyPair(): Promise<{ publicKey: ByteArray; privateKey: ByteArray }>; - generateEd25519KeyPair(): Promise<{ publicKey: ByteArray; privateKey: ByteArray }>; - generateSymmetricKey(length: 16 | 32): Promise; // raw bytes (16=128, 32=256) - diffieHellman(privateKey: ByteArray, publicKey: ByteArray): Promise; // X25519 - ed25519Sign(privateKey: ByteArray, message: ByteArray): Promise; - ed25519Verify(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Promise; - aeadEncrypt( - key: ByteArray, - nonce: ByteArray, - plaintext: ByteArray, - aad?: ByteArray, - algorithm?: 'AES-256-GCM' | 'CHACHA20-POLY1305', - ): Promise; - aeadDecrypt( - key: ByteArray, - nonce: ByteArray, - ciphertext: ByteArray, - aad?: ByteArray, - algorithm?: 'AES-256-GCM' | 'CHACHA20-POLY1305', - ): Promise; - kdf(chainKey: ByteArray, info: ByteArray): Promise<{ nextChainKey: ByteArray; messageKey: ByteArray }>; -} - -export interface KeyStorageProvider { - storeIdentityKey(key: KeyMaterial): Promise; - loadIdentityKey(): Promise; - storeSessionState(peerId: string, state: unknown): Promise; - loadSessionState(peerId: string): Promise; - storeGroupState(groupId: string, state: unknown): Promise; - loadGroupState(groupId: string): Promise; - listPeerSessions?(): Promise; // optional introspection -} - -export interface NetworkProvider { - // All methods exchange opaque byte arrays / JSON-serializable records; no transport assumptions - publishPreKeyBundle(bundle: PreKeyBundle): Promise; - fetchPreKeyBundle(peerId: string): Promise; - submitGroupMessage(groupId: string, envelope: GroupNetworkEnvelope): Promise; - fetchGroupMessages(groupId: string, since?: number): Promise; -} - -export interface PreKeyBundle { - identityKey: ByteArray; // Ed25519 public - signedPreKey: ByteArray; // X25519 public (signed) - signedPreKeySignature: ByteArray; // Ed25519 signature - oneTimePreKeys?: ByteArray[]; // optional X25519 - timestamp: number; -} - -export interface GroupNetworkEnvelope { - groupId: string; - senderId: string; - payload: ByteArray; // serialized GroupEnvelope - sentAt: number; -} - -export interface ProviderContext { - crypto: CryptoProvider; - rng: RandomnessProvider; - hash: HashProvider; - storage: KeyStorageProvider; - net?: NetworkProvider; // optional if only local operations -} diff --git a/packages/e2e-crypto-core/src/types.ts b/packages/e2e-crypto-core/src/types.ts deleted file mode 100644 index 24b82877f1b90..0000000000000 --- a/packages/e2e-crypto-core/src/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* Core shared types for the E2E crypto core */ - -export type ByteArray = Uint8Array; // explicit alias for clarity - -export interface KeyMaterial { - id: string; // stable identifier (e.g. KID / hash prefix) - publicKey?: ByteArray; // raw public part when asymmetric - secretKey?: ByteArray; // raw secret/symmetric key bytes (NEVER store here in production key store without encryption) - algorithm: string; // e.g. 'X25519', 'Ed25519', 'AES-256-GCM' - createdAt: number; - expiresAt?: number; - meta?: Record; -} - -export interface RatchetStateSnapshot { - rootKey: ByteArray; - sendChainKey: ByteArray; - recvChainKey: ByteArray; - sendIndex: number; - recvIndex: number; - theirEphemeralPub?: ByteArray; - ourEphemeralKeyPair?: { pub: ByteArray; priv: ByteArray }; - previousCounter?: number; // for skipped message handling - skippedMessageKeys?: Record; // keyId -> key - version: 1; -} - -export interface EncryptedPayload { - version: number; // protocol version - header: RatchetHeader; // ratchet header - ciphertext: ByteArray; // AEAD ciphertext (includes auth tag) - associatedData?: ByteArray; // optional external AD used when encrypting - messageId: string; // unique per message (nonce/uuid) - sentAt: number; // epoch ms - senderKeyId: string; // used for sender identity / verification - digest?: ByteArray; // optional MAC/HMAC if layered -} - -export interface RatchetHeader { - ephemeralPub: ByteArray; // sender's current DH ratchet public key - previousCounter: number; // number of messages in previous sending chain - messageIndex: number; // index within current sending chain -} - -export interface DecryptionResult { - plaintext: ByteArray; - header: RatchetHeader; - messageId: string; - replay: boolean; // true if rejected / duplicate would be flagged upstream -} - -export interface GroupEnvelope { - groupId: string; - senderUserId: string; - payload: EncryptedPayload; - membershipVersion: number; // for group key state consistency -} - -export interface KeyDerivationParams { - salt: ByteArray; - info?: ByteArray; - iterations?: number; - algorithm?: string; // e.g. 'HKDF-SHA256', 'PBKDF2-SHA256' - length: number; // desired bytes -} - -export interface ProtocolErrorDetails { - code: string; - message: string; - cause?: unknown; - context?: Record; -} - -export class ProtocolError extends Error { - readonly code: string; - readonly cause?: unknown; - readonly context?: Record; - constructor(details: ProtocolErrorDetails) { - super(details.message); - this.code = details.code; - this.cause = details.cause; - this.context = details.context; - } -} - -export type IdentityKeyPair = { publicKey: ByteArray; privateKey: ByteArray; algorithm: string }; -export type EphemeralKeyPair = { publicKey: ByteArray; privateKey: ByteArray; algorithm: string }; - -export interface SealedMessage { - envelope: EncryptedPayload | GroupEnvelope; - serialized: ByteArray; // canonical encoding output (provided by higher layer codec) -} diff --git a/apps/meteor/app/e2e/client/wordList.ts b/packages/e2e-crypto-core/src/wordList.ts similarity index 99% rename from apps/meteor/app/e2e/client/wordList.ts rename to packages/e2e-crypto-core/src/wordList.ts index f2930b6cb9697..a6b1e480cc5f7 100644 --- a/apps/meteor/app/e2e/client/wordList.ts +++ b/packages/e2e-crypto-core/src/wordList.ts @@ -1,4 +1,4 @@ -export default [ +export const v1: string[] = [ 'acrobat', 'africa', 'alaska', diff --git a/packages/e2e-crypto-core/tsconfig.build.json b/packages/e2e-crypto-core/tsconfig.build.json index d379bcec51674..cf4278ae09d96 100644 --- a/packages/e2e-crypto-core/tsconfig.build.json +++ b/packages/e2e-crypto-core/tsconfig.build.json @@ -1,17 +1,7 @@ { - "extends": "../../tsconfig/tsconfig.base.json", + "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "composite": false, - "strict": true, - "module": "ESNext", - "target": "ES2022", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "skipLibCheck": true + "noEmit": false }, "include": ["src"], "exclude": ["src/**/*.test.ts"] diff --git a/packages/e2e-crypto-core/tsconfig.json b/packages/e2e-crypto-core/tsconfig.json new file mode 100644 index 0000000000000..a7c530af96679 --- /dev/null +++ b/packages/e2e-crypto-core/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "composite": false, + "strict": true, + "module": "node20", + "moduleDetection": "force", + "target": "ES2024", + "lib": ["ES2024", "ES2024.Promise", "ES2022.Error"], + "skipLibCheck": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "libReplacement": false, + "isolatedDeclarations": true + }, + "include": ["src"], + "exclude": [] +} diff --git a/yarn.lock b/yarn.lock index ca0402217e0fc..e64503500bbab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2404,6 +2404,13 @@ __metadata: languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.12.1": + version: 4.12.1 + resolution: "@eslint-community/regexpp@npm:4.12.1" + checksum: 10/c08f1dd7dd18fbb60bdd0d85820656d1374dd898af9be7f82cb00451313402a22d5e30569c150315b4385907cdbca78c22389b2a72ab78883b3173be317620cc + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.4.0": version: 4.5.1 resolution: "@eslint-community/regexpp@npm:4.5.1" @@ -2411,6 +2418,33 @@ __metadata: languageName: node linkType: hard +"@eslint/config-array@npm:^0.21.0": + version: 0.21.0 + resolution: "@eslint/config-array@npm:0.21.0" + dependencies: + "@eslint/object-schema": "npm:^2.1.6" + debug: "npm:^4.3.1" + minimatch: "npm:^3.1.2" + checksum: 10/f5a499e074ecf4b4a5efdca655418a12079d024b77d02fd35868eeb717c5bfdd8e32c6e8e1dd125330233a878026edda8062b13b4310169ba5bfee9623a67aa0 + languageName: node + linkType: hard + +"@eslint/config-helpers@npm:^0.3.1": + version: 0.3.1 + resolution: "@eslint/config-helpers@npm:0.3.1" + checksum: 10/fc1a90ef6180aa4b5187cee04cfc566abb2a32b77ca3e7eeb4312c7388f6898221adaf8451d9ddb22e0b8860d900fefb1eb1435e4f32f8d8732de87f14605f8f + languageName: node + linkType: hard + +"@eslint/core@npm:^0.15.2": + version: 0.15.2 + resolution: "@eslint/core@npm:0.15.2" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10/41d6273bbc6897cca34a2ca4e80a24bf6f1d43519456ebaa3c38f187da2d9e06f442c64f6e2a2813f055dce35e5cea33a21d0ac3b5b0830b7165641c640faf5d + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.0": version: 2.1.0 resolution: "@eslint/eslintrc@npm:2.1.0" @@ -2428,6 +2462,23 @@ __metadata: languageName: node linkType: hard +"@eslint/eslintrc@npm:^3.3.1": + version: 3.3.1 + resolution: "@eslint/eslintrc@npm:3.3.1" + dependencies: + ajv: "npm:^6.12.4" + debug: "npm:^4.3.2" + espree: "npm:^10.0.1" + globals: "npm:^14.0.0" + ignore: "npm:^5.2.0" + import-fresh: "npm:^3.2.1" + js-yaml: "npm:^4.1.0" + minimatch: "npm:^3.1.2" + strip-json-comments: "npm:^3.1.1" + checksum: 10/cc240addbab3c5fceaa65b2c8d5d4fd77ddbbf472c2f74f0270b9d33263dc9116840b6099c46b64c9680301146250439b044ed79278a1bcc557da412a4e3c1bb + languageName: node + linkType: hard + "@eslint/js@npm:8.44.0": version: 8.44.0 resolution: "@eslint/js@npm:8.44.0" @@ -2435,6 +2486,30 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:9.33.0": + version: 9.33.0 + resolution: "@eslint/js@npm:9.33.0" + checksum: 10/415031162eeee4ed67457585f3a3f3442521e75dd352932582683452c393d837da81cf9726a2cf097b444119ae2e405951e6b5d84546f67b6370fc36f27d8321 + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^2.1.6": + version: 2.1.6 + resolution: "@eslint/object-schema@npm:2.1.6" + checksum: 10/266085c8d3fa6cd99457fb6350dffb8ee39db9c6baf28dc2b86576657373c92a568aec4bae7d142978e798b74c271696672e103202d47a0c148da39154351ed6 + languageName: node + linkType: hard + +"@eslint/plugin-kit@npm:^0.3.5": + version: 0.3.5 + resolution: "@eslint/plugin-kit@npm:0.3.5" + dependencies: + "@eslint/core": "npm:^0.15.2" + levn: "npm:^0.4.1" + checksum: 10/b8552d79c3091446b07d8b87a9a8ccb8cdee4d933c0ed46b8f61029c3382246fec8d04ea7d1e61656d9275263205ccaa40019fd7581bbce897eca3eda42d5dad + languageName: node + linkType: hard + "@faker-js/faker@npm:~8.0.2": version: 8.0.2 resolution: "@faker-js/faker@npm:8.0.2" @@ -2623,6 +2698,23 @@ __metadata: languageName: node linkType: hard +"@humanfs/core@npm:^0.19.1": + version: 0.19.1 + resolution: "@humanfs/core@npm:0.19.1" + checksum: 10/270d936be483ab5921702623bc74ce394bf12abbf57d9145a69e8a0d1c87eb1c768bd2d93af16c5705041e257e6d9cc7529311f63a1349f3678abc776fc28523 + languageName: node + linkType: hard + +"@humanfs/node@npm:^0.16.6": + version: 0.16.6 + resolution: "@humanfs/node@npm:0.16.6" + dependencies: + "@humanfs/core": "npm:^0.19.1" + "@humanwhocodes/retry": "npm:^0.3.0" + checksum: 10/6d43c6727463772d05610aa05c83dab2bfbe78291022ee7a92cb50999910b8c720c76cc312822e2dea2b497aa1b3fef5fe9f68803fc45c9d4ed105874a65e339 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.10": version: 0.11.10 resolution: "@humanwhocodes/config-array@npm:0.11.10" @@ -2648,6 +2740,20 @@ __metadata: languageName: node linkType: hard +"@humanwhocodes/retry@npm:^0.3.0": + version: 0.3.1 + resolution: "@humanwhocodes/retry@npm:0.3.1" + checksum: 10/eb457f699529de7f07649679ec9e0353055eebe443c2efe71c6dd950258892475a038e13c6a8c5e13ed1fb538cdd0a8794faa96b24b6ffc4c87fb1fc9f70ad7f + languageName: node + linkType: hard + +"@humanwhocodes/retry@npm:^0.4.2": + version: 0.4.3 + resolution: "@humanwhocodes/retry@npm:0.4.3" + checksum: 10/0b32cfd362bea7a30fbf80bb38dcaf77fee9c2cae477ee80b460871d03590110ac9c77d654f04ec5beaf71b6f6a89851bdf6c1e34ccdf2f686bd86fcd97d9e61 + languageName: node + linkType: hard + "@img/sharp-darwin-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-darwin-arm64@npm:0.33.5" @@ -7250,6 +7356,18 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/e2e-crypto-core@workspace:^, @rocket.chat/e2e-crypto-core@workspace:packages/e2e-crypto-core": + version: 0.0.0-use.local + resolution: "@rocket.chat/e2e-crypto-core@workspace:packages/e2e-crypto-core" + dependencies: + "@types/node": "npm:^24.2.1" + eslint: "npm:^9.10.0" + jest: "npm:~30.0.5" + rimraf: "npm:^5.0.5" + typescript: "npm:~5.9.2" + languageName: unknown + linkType: soft + "@rocket.chat/emitter@npm:~0.31.25": version: 0.31.25 resolution: "@rocket.chat/emitter@npm:0.31.25" @@ -7885,6 +8003,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/cron": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" + "@rocket.chat/e2e-crypto-core": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" @@ -11959,6 +12078,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.2.1": + version: 24.2.1 + resolution: "@types/node@npm:24.2.1" + dependencies: + undici-types: "npm:~7.10.0" + checksum: 10/cdfa7b30b2f1de71c4cf0b66ee05c71c8f39819a817bb78b4ba10545d4ca1ea9988a3614885806e90401e92938aa08e08489b1b920ba5c1406a028295019734f + languageName: node + linkType: hard + "@types/node@npm:~22.14.1": version: 22.14.1 resolution: "@types/node@npm:22.14.1" @@ -13498,6 +13626,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.15.0": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 + languageName: node + linkType: hard + "add-px-to-style@npm:1.0.0": version: 1.0.0 resolution: "add-px-to-style@npm:1.0.0" @@ -19381,6 +19518,16 @@ __metadata: languageName: node linkType: hard +"eslint-scope@npm:^8.4.0": + version: 8.4.0 + resolution: "eslint-scope@npm:8.4.0" + dependencies: + esrecurse: "npm:^4.3.0" + estraverse: "npm:^5.2.0" + checksum: 10/e8e611701f65375e034c62123946e628894f0b54aa8cb11abe224816389abe5cd74cf16b62b72baa36504f22d1a958b9b8b0169b82397fe2e7997674c0d09b06 + languageName: node + linkType: hard + "eslint-visitor-keys@npm:^2.1.0": version: 2.1.0 resolution: "eslint-visitor-keys@npm:2.1.0" @@ -19395,6 +19542,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10/3ee00fc6a7002d4b0ffd9dc99e13a6a7882c557329e6c25ab254220d71e5c9c4f89dca4695352949ea678eb1f3ba912a18ef8aac0a7fe094196fd92f441bfce2 + languageName: node + linkType: hard + "eslint4b-prebuilt@npm:^6.7.2": version: 6.7.2 resolution: "eslint4b-prebuilt@npm:6.7.2" @@ -19402,6 +19556,56 @@ __metadata: languageName: node linkType: hard +"eslint@npm:^9.10.0": + version: 9.33.0 + resolution: "eslint@npm:9.33.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/regexpp": "npm:^4.12.1" + "@eslint/config-array": "npm:^0.21.0" + "@eslint/config-helpers": "npm:^0.3.1" + "@eslint/core": "npm:^0.15.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:9.33.0" + "@eslint/plugin-kit": "npm:^0.3.5" + "@humanfs/node": "npm:^0.16.6" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.4.2" + "@types/estree": "npm:^1.0.6" + "@types/json-schema": "npm:^7.0.15" + ajv: "npm:^6.12.4" + chalk: "npm:^4.0.0" + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.3.2" + escape-string-regexp: "npm:^4.0.0" + eslint-scope: "npm:^8.4.0" + eslint-visitor-keys: "npm:^4.2.1" + espree: "npm:^10.4.0" + esquery: "npm:^1.5.0" + esutils: "npm:^2.0.2" + fast-deep-equal: "npm:^3.1.3" + file-entry-cache: "npm:^8.0.0" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" + imurmurhash: "npm:^0.1.4" + is-glob: "npm:^4.0.0" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + lodash.merge: "npm:^4.6.2" + minimatch: "npm:^3.1.2" + natural-compare: "npm:^1.4.0" + optionator: "npm:^0.9.3" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + bin: + eslint: bin/eslint.js + checksum: 10/3857f17460c8a245e3dd2d23b892ce844dc7d337db3fbdeb39f488d6405ae6e71f2142cc4514316d0042c85a77d9503e7dfd4637665eb389473145ad31bd8306 + languageName: node + linkType: hard + "eslint@npm:~8.45.0": version: 8.45.0 resolution: "eslint@npm:8.45.0" @@ -19449,6 +19653,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.0.1, espree@npm:^10.4.0": + version: 10.4.0 + resolution: "espree@npm:10.4.0" + dependencies: + acorn: "npm:^8.15.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/9b355b32dbd1cc9f57121d5ee3be258fab87ebeb7c83fc6c02e5af1a74fc8c5ba79fe8c663e69ea112c3e84a1b95e6a2067ac4443ee7813bb85ac7581acb8bf9 + languageName: node + linkType: hard + "espree@npm:^9.6.0": version: 9.6.0 resolution: "espree@npm:9.6.0" @@ -19479,6 +19694,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.5.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10/c587fb8ec9ed83f2b1bc97cf2f6854cc30bf784a79d62ba08c6e358bf22280d69aee12827521cf38e69ae9761d23fb7fde593ce315610f85655c139d99b05e5a + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -20104,6 +20328,15 @@ __metadata: languageName: node linkType: hard +"file-entry-cache@npm:^8.0.0": + version: 8.0.0 + resolution: "file-entry-cache@npm:8.0.0" + dependencies: + flat-cache: "npm:^4.0.0" + checksum: 10/afe55c4de4e0d226a23c1eae62a7219aafb390859122608a89fa4df6addf55c7fd3f1a2da6f5b41e7cdff496e4cf28bbd215d53eab5c817afa96d2b40c81bfb0 + languageName: node + linkType: hard + "file-entry-cache@npm:^9.1.0": version: 9.1.0 resolution: "file-entry-cache@npm:9.1.0" @@ -20443,6 +20676,16 @@ __metadata: languageName: node linkType: hard +"flat-cache@npm:^4.0.0": + version: 4.0.1 + resolution: "flat-cache@npm:4.0.1" + dependencies: + flatted: "npm:^3.2.9" + keyv: "npm:^4.5.4" + checksum: 10/58ce851d9045fffc7871ce2bd718bc485ad7e777bf748c054904b87c351ff1080c2c11da00788d78738bfb51b71e4d5ea12d13b98eb36e3358851ffe495b62dc + languageName: node + linkType: hard + "flat-cache@npm:^5.0.0": version: 5.0.0 resolution: "flat-cache@npm:5.0.0" @@ -20469,6 +20712,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.2.9": + version: 3.3.3 + resolution: "flatted@npm:3.3.3" + checksum: 10/8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe + languageName: node + linkType: hard + "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -21208,6 +21458,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^14.0.0": + version: 14.0.0 + resolution: "globals@npm:14.0.0" + checksum: 10/03939c8af95c6df5014b137cac83aa909090c3a3985caef06ee9a5a669790877af8698ab38007e4c0186873adc14c0b13764acc754b16a754c216cc56aa5f021 + languageName: node + linkType: hard + "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -35044,6 +35301,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:~5.9.2": + version: 5.9.2 + resolution: "typescript@npm:5.9.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/cc2fe6c822819de5d453fa25aa9f32096bf70dde215d481faa1ad84a283dfb264e33988ed8f6d36bc803dd0b16dbe943efa311a798ef76d5b3892a05dfbfd628 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A~5.8.3#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" @@ -35054,6 +35321,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A~5.9.2#optional!builtin": + version: 5.9.2 + resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/bd810ab13e8e557225a8b5122370385440b933e4e077d5c7641a8afd207fdc8be9c346e3c678adba934b64e0e70b0acf5eef9493ea05170a48ce22bef845fdc7 + languageName: node + linkType: hard + "typia@npm:~9.3.1": version: 9.3.1 resolution: "typia@npm:9.3.1" @@ -35162,6 +35439,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.10.0": + version: 7.10.0 + resolution: "undici-types@npm:7.10.0" + checksum: 10/1f3fe777937690ab8a7a7bccabc8fdf4b3171f4899b5a384fb5f3d6b56c4b5fec2a51fbf345c9dd002ff6716fd440a37fa8fdb0e13af8eca8889f25445875ba3 + languageName: node + linkType: hard + "undici@npm:^5.25.4, undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" From a50fca77b29fb081413f7df2451d3462ed26c926 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 13 Aug 2025 15:28:02 -0300 Subject: [PATCH 003/251] chore: update ts version --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 11 +- apps/meteor/app/otr/client/OTRRoom.ts | 4 +- apps/meteor/app/otr/lib/IOTR.ts | 2 +- apps/meteor/app/otr/lib/functions.ts | 8 +- .../server/methods/getFileFromWebdav.ts | 2 +- apps/meteor/ee/server/services/package.json | 4 +- apps/meteor/package.json | 6 +- apps/uikit-playground/package.json | 2 +- ee/apps/account-service/package.json | 4 +- ee/apps/authorization-service/package.json | 4 +- ee/apps/ddp-streamer/package.json | 4 +- ee/apps/omnichannel-transcript/package.json | 4 +- ee/apps/presence-service/package.json | 4 +- ee/apps/queue-worker/package.json | 4 +- ee/apps/stream-hub-service/package.json | 4 +- ee/packages/license/package.json | 2 +- ee/packages/network-broker/package.json | 4 +- ee/packages/omni-core-ee/package.json | 2 +- ee/packages/omnichannel-services/package.json | 4 +- ee/packages/pdf-worker/package.json | 2 +- ee/packages/presence/package.json | 4 +- ee/packages/ui-theming/package.json | 2 +- package.json | 7 +- packages/account-utils/package.json | 2 +- packages/agenda/package.json | 2 +- packages/api-client/package.json | 2 +- packages/apps-engine/package.json | 4 +- packages/apps/package.json | 2 +- packages/base64/package.json | 2 +- packages/cas-validate/package.json | 2 +- packages/core-services/package.json | 2 +- packages/core-typings/package.json | 2 +- packages/cron/package.json | 2 +- packages/ddp-client/package.json | 2 +- packages/e2e-crypto-core/.gitignore | 1 - .../e2e-crypto-core/scripts/postbuild.cjs | 19 - .../src/__tests__/e2ee.test.ts | 63 --- packages/e2e-crypto-core/tsconfig.json | 29 -- packages/{e2e-crypto-core => e2ee}/README.md | 0 .../{e2e-crypto-core => e2ee}/package.json | 10 +- packages/e2ee/scripts/postbuild.ts | 6 + packages/e2ee/src/__tests__/e2ee.test.ts | 59 +++ .../{e2e-crypto-core => e2ee}/src/index.ts | 13 - packages/e2ee/src/storage/localStorage.ts | 0 packages/e2ee/src/storage/memoryStorage.ts | 21 + .../{e2e-crypto-core => e2ee}/src/wordList.ts | 0 .../tsconfig.build.json | 0 packages/e2ee/tsconfig.json | 32 ++ packages/eslint-config/package.json | 2 +- packages/favicon/package.json | 2 +- packages/freeswitch/package.json | 2 +- packages/fuselage-ui-kit/package.json | 2 +- packages/gazzodown/package.json | 2 +- packages/http-router/package.json | 2 +- packages/i18n/package.json | 2 +- packages/instance-status/package.json | 2 +- packages/jest-presets/package.json | 2 +- .../jest-presets/src/client/jest-setup.ts | 2 +- packages/jwt/package.json | 2 +- packages/livechat/package.json | 2 +- packages/log-format/package.json | 2 +- packages/logger/package.json | 2 +- packages/message-parser/package.json | 4 +- packages/mock-providers/package.json | 2 +- packages/model-typings/package.json | 2 +- packages/models/package.json | 2 +- packages/mongo-adapter/package.json | 2 +- packages/node-poplib/package.json | 2 +- packages/omni-core/package.json | 2 +- packages/password-policies/package.json | 2 +- packages/patch-injection/package.json | 2 +- packages/peggy-loader/package.json | 4 +- packages/random/package.json | 2 +- packages/release-action/package.json | 4 +- packages/release-changelog/package.json | 4 +- packages/rest-typings/package.json | 2 +- .../server-cloud-communication/package.json | 2 +- packages/server-fetch/package.json | 2 +- packages/sha256/package.json | 2 +- packages/storybook-config/package.json | 2 +- packages/tools/package.json | 2 +- packages/tracing/package.json | 2 +- packages/ui-avatar/package.json | 2 +- packages/ui-client/package.json | 2 +- packages/ui-composer/package.json | 2 +- packages/ui-contexts/package.json | 2 +- packages/ui-kit/package.json | 2 +- packages/ui-video-conf/package.json | 2 +- packages/ui-voip/package.json | 2 +- packages/web-ui-registration/package.json | 2 +- yarn.lock | 487 ++++-------------- 91 files changed, 325 insertions(+), 625 deletions(-) delete mode 100644 packages/e2e-crypto-core/.gitignore delete mode 100644 packages/e2e-crypto-core/scripts/postbuild.cjs delete mode 100644 packages/e2e-crypto-core/src/__tests__/e2ee.test.ts delete mode 100644 packages/e2e-crypto-core/tsconfig.json rename packages/{e2e-crypto-core => e2ee}/README.md (100%) rename packages/{e2e-crypto-core => e2ee}/package.json (72%) create mode 100644 packages/e2ee/scripts/postbuild.ts create mode 100644 packages/e2ee/src/__tests__/e2ee.test.ts rename packages/{e2e-crypto-core => e2ee}/src/index.ts (80%) create mode 100644 packages/e2ee/src/storage/localStorage.ts create mode 100644 packages/e2ee/src/storage/memoryStorage.ts rename packages/{e2e-crypto-core => e2ee}/src/wordList.ts (100%) rename packages/{e2e-crypto-core => e2ee}/tsconfig.build.json (100%) create mode 100644 packages/e2ee/tsconfig.json diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index eeb1dbd6cf13c..238ce7c5b5d0a 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { E2EE, type KeyPair } from '@rocket.chat/e2e-crypto-core'; +import { E2EE, type KeyPair } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -75,7 +75,14 @@ class E2E extends Emitter { this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = E2EE.fromWeb(Accounts.storageLocation, crypto); + this.e2ee = new E2EE( + { + load: (keyName) => Promise.resolve(Accounts.storageLocation.getItem(keyName)), + store: (keyName, value) => Promise.resolve(Accounts.storageLocation.setItem(keyName, value)), + remove: (keyName) => Promise.resolve(Accounts.storageLocation.removeItem(keyName)), + }, + crypto, + ); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); diff --git a/apps/meteor/app/otr/client/OTRRoom.ts b/apps/meteor/app/otr/client/OTRRoom.ts index 65698793cc879..9c5fe88af67d6 100644 --- a/apps/meteor/app/otr/client/OTRRoom.ts +++ b/apps/meteor/app/otr/client/OTRRoom.ts @@ -263,7 +263,7 @@ export class OTRRoom implements IOTRRoom { } } - async encryptText(data: string | Uint8Array): Promise { + async encryptText(data: string | Uint8Array): Promise { if (typeof data === 'string') { data = new TextEncoder().encode(EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) })); } @@ -304,7 +304,7 @@ export class OTRRoom implements IOTRRoom { try { if (!this._sessionKey) throw new Error('Session Key not available.'); - const cipherText: Uint8Array = EJSON.parse(message); + const cipherText: Uint8Array = EJSON.parse(message); const data = await decryptAES(cipherText, this._sessionKey); const msgDecoded: IOTRDecrypt = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(data))); if (msgDecoded && typeof msgDecoded === 'object') { diff --git a/apps/meteor/app/otr/lib/IOTR.ts b/apps/meteor/app/otr/lib/IOTR.ts index c7aeb0a3777ac..c015c0dd425f6 100644 --- a/apps/meteor/app/otr/lib/IOTR.ts +++ b/apps/meteor/app/otr/lib/IOTR.ts @@ -11,7 +11,7 @@ export interface IOnUserStreamData { } export interface IOTRDecrypt { - ack: string | Uint8Array; + ack: string | Uint8Array; text: string; ts: Date; userId: IUser['_id']; diff --git a/apps/meteor/app/otr/lib/functions.ts b/apps/meteor/app/otr/lib/functions.ts index 3f2b14e8fdaa2..78dfb4b6d4fef 100644 --- a/apps/meteor/app/otr/lib/functions.ts +++ b/apps/meteor/app/otr/lib/functions.ts @@ -14,9 +14,9 @@ export const encryptAES = async ({ _sessionKey, data, }: { - iv: Uint8Array; + iv: Uint8Array; _sessionKey: CryptoKey; - data: Uint8Array; + data: Uint8Array; }): Promise => subtle.encrypt( { @@ -52,7 +52,7 @@ export const importKey = async (publicKeyObject: JsonWebKey): Promise false, [], ); -export const importKeyRaw = async (sessionKeyData: Uint8Array): Promise => +export const importKeyRaw = async (sessionKeyData: Uint8Array): Promise => subtle.importKey( 'raw', sessionKeyData, @@ -72,7 +72,7 @@ export const generateKeyPair = async (): Promise => false, ['deriveKey', 'deriveBits'], ); -export const decryptAES = async (cipherText: Uint8Array, _sessionKey: CryptoKey): Promise => { +export const decryptAES = async (cipherText: Uint8Array, _sessionKey: CryptoKey): Promise => { const iv = cipherText.slice(0, 12); cipherText = cipherText.slice(12); const data = await subtle.decrypt( diff --git a/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts b/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts index 38aeb0442c5c6..f8a2281586e44 100644 --- a/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts +++ b/apps/meteor/app/webdav/server/methods/getFileFromWebdav.ts @@ -10,7 +10,7 @@ import { WebdavClientAdapter } from '../lib/webdavClientAdapter'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array }>; + getFileFromWebdav(accountId: IWebdavAccount['_id'], file: IWebdavNode): Promise<{ success: boolean; data: Uint8Array }>; } } diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 7af5b2c12a185..e7aa458c1a3e6 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -54,12 +54,12 @@ "@types/ejson": "^2.2.2", "@types/express": "^4.17.23", "@types/fibers": "^3.1.4", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@types/ws": "^8.5.13", "npm-run-all": "^4.1.5", "pino-pretty": "^7.6.1", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../../../../package.json" diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 5cf01dce81f0a..3da0894c8c645 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -131,7 +131,7 @@ "@types/meteor-collection-hooks": "^0.8.9", "@types/mkdirp": "^1.0.2", "@types/mocha": "github:whitecolor/mocha-types", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@types/node-rsa": "^1.1.4", "@types/nodemailer": "^6.4.17", "@types/oauth2-server": "^3.0.18", @@ -217,7 +217,7 @@ "supports-color": "~7.2.0", "template-file": "^6.0.1", "ts-node": "^10.9.2", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "dependencies": { @@ -249,7 +249,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/e2e-crypto-core": "workspace:^", + "@rocket.chat/e2ee": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 847fc3e0bbdd3..0247c433aae44 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -55,7 +55,7 @@ "eslint": "~8.45.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.20", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "vite": "^6.2.4" }, "volta": { diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index 2a575974cb3f1..df228f7273eff 100644 --- a/ee/apps/account-service/package.json +++ b/ee/apps/account-service/package.json @@ -26,7 +26,7 @@ "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tools": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "bcrypt": "^5.1.1", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", @@ -48,7 +48,7 @@ "@types/prometheus-gc-stats": "^0.6.4", "eslint": "~8.45.0", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/account-service/src/service.js", "files": [ diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index b618011f14bbb..d7d563faf8179 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -25,7 +25,7 @@ "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", @@ -44,7 +44,7 @@ "@types/prometheus-gc-stats": "^0.6.4", "eslint": "~8.45.0", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/authorization-service/src/service.js", "files": [ diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index c401d55de9304..82e853fb73e62 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -48,7 +48,7 @@ "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", "@types/ejson": "^2.2.2", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@types/polka": "^0.5.7", "@types/prometheus-gc-stats": "^0.6.4", "@types/underscore": "^1.13.0", @@ -57,7 +57,7 @@ "eslint": "~8.45.0", "pino-pretty": "^7.6.1", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/service.js", "files": [ diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index 4916b4e9931fe..95b26b714b8ba 100644 --- a/ee/apps/omnichannel-transcript/package.json +++ b/ee/apps/omnichannel-transcript/package.json @@ -28,7 +28,7 @@ "@rocket.chat/pdf-worker": "workspace:^", "@rocket.chat/tools": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", "event-loop-stats": "^1.4.1", @@ -55,7 +55,7 @@ "eslint": "~8.45.0", "react": "~18.3.1", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/omnichannel-transcript/src/service.js", "files": [ diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index 766e892f68e2c..0c46115041989 100644 --- a/ee/apps/presence-service/package.json +++ b/ee/apps/presence-service/package.json @@ -25,7 +25,7 @@ "@rocket.chat/presence": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", @@ -44,7 +44,7 @@ "@types/prometheus-gc-stats": "^0.6.4", "eslint": "~8.45.0", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/presence-service/src/service.js", "files": [ diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index 6a71986a2a4ab..81e2f35f0d414 100644 --- a/ee/apps/queue-worker/package.json +++ b/ee/apps/queue-worker/package.json @@ -24,7 +24,7 @@ "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", "event-loop-stats": "^1.4.1", @@ -46,7 +46,7 @@ "@types/prometheus-gc-stats": "^0.6.4", "eslint": "~8.45.0", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/queue-worker/src/service.js", "files": [ diff --git a/ee/apps/stream-hub-service/package.json b/ee/apps/stream-hub-service/package.json index 9747e06ff47e9..62e66499f019f 100644 --- a/ee/apps/stream-hub-service/package.json +++ b/ee/apps/stream-hub-service/package.json @@ -24,7 +24,7 @@ "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", @@ -45,7 +45,7 @@ "@types/prometheus-gc-stats": "^0.6.4", "eslint": "~8.45.0", "ts-node": "^10.9.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./dist/ee/apps/stream-hub-service/src/service.js", "files": [ diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index 1af205885d96f..b3fc3103e689c 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -11,7 +11,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "jest-websocket-mock": "~2.5.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "tsc", diff --git a/ee/packages/network-broker/package.json b/ee/packages/network-broker/package.json index 65ce02b4f2425..db9135dffe485 100644 --- a/ee/packages/network-broker/package.json +++ b/ee/packages/network-broker/package.json @@ -7,13 +7,13 @@ "@rocket.chat/tsconfig": "workspace:*", "@types/chai": "~4.3.20", "@types/ejson": "^2.2.2", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@types/sinon": "^10.0.20", "chai": "^4.5.0", "eslint": "~8.45.0", "jest": "~30.0.5", "sinon": "^19.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint src", diff --git a/ee/packages/omni-core-ee/package.json b/ee/packages/omni-core-ee/package.json index 82f5ff90630f6..d3fb87b08bcc7 100644 --- a/ee/packages/omni-core-ee/package.json +++ b/ee/packages/omni-core-ee/package.json @@ -9,7 +9,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index cef80379c0ee9..cace257ded13a 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -9,7 +9,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "dependencies": { "@rocket.chat/core-services": "workspace:^", @@ -23,7 +23,7 @@ "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tools": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "date-fns": "~4.1.0", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index c6c4405ffdad5..1591652aac5b9 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -45,7 +45,7 @@ "jest": "~30.0.5", "react-dom": "~18.3.1", "storybook": "^8.6.14", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../../package.json" diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 41a11648175d4..389fb47839fe1 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -9,11 +9,11 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "babel-jest": "~30.0.5", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint src", diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index 5a02bfcfbd38c..c0a3453f03a54 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -22,7 +22,7 @@ "react-docgen-typescript-plugin": "~1.0.8", "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "scripts": { diff --git a/package.json b/package.json index 3efa751179bca..383a0b6b72baa 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,7 @@ }, "devDependencies": { "@changesets/cli": "^2.27.11", - "@types/chart.js": "^2.9.41", - "@types/js-yaml": "^4.0.9", - "@types/node": "~22.14.1", - "ts-node": "^10.9.2", - "turbo": "~2.5.5", - "typescript": "~5.8.3" + "turbo": "~2.5.5" }, "workspaces": [ "apps/*", diff --git a/packages/account-utils/package.json b/packages/account-utils/package.json index 3213b677388f3..b41a60e566b84 100644 --- a/packages/account-utils/package.json +++ b/packages/account-utils/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/agenda/package.json b/packages/agenda/package.json index adcaa071995cd..08d4080f044b0 100644 --- a/packages/agenda/package.json +++ b/packages/agenda/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@types/debug": "^4.1.12", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index e4919556c1a18..cf0d0033679f8 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -9,7 +9,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "jest-fetch-mock": "^3.0.3", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "tsc", diff --git a/packages/apps-engine/package.json b/packages/apps-engine/package.json index 4ee3c9edab0ec..fa46d556b4b6d 100644 --- a/packages/apps-engine/package.json +++ b/packages/apps-engine/package.json @@ -75,7 +75,7 @@ "@types/adm-zip": "^0.5.7", "@types/debug": "^4.1.12", "@types/lodash.clonedeep": "^4.5.9", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@types/semver": "^7.5.8", "@types/stack-trace": "0.0.33", "@types/uuid": "~10.0.0", @@ -90,7 +90,7 @@ "tap-bark": "^1.0.0", "ts-node": "^6.2.0", "typedoc": "~0.28.5", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "uglify-es": "^3.3.10" }, "dependencies": { diff --git a/packages/apps/package.json b/packages/apps/package.json index fdf166cb91e27..346e993f2db36 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@rocket.chat/tsconfig": "workspace:*", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/base64/package.json b/packages/base64/package.json index 97039d12a6d4a..54ba191f4cf31 100644 --- a/packages/base64/package.json +++ b/packages/base64/package.json @@ -22,7 +22,7 @@ "@typescript-eslint/parser": "~5.60.1", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/cas-validate/package.json b/packages/cas-validate/package.json index 3342ff0bc8611..9256f8d10ae0b 100644 --- a/packages/cas-validate/package.json +++ b/packages/cas-validate/package.json @@ -6,7 +6,7 @@ "devDependencies": { "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index a1441b185c9d8..3dbb0a221bf30 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -16,7 +16,7 @@ "jest": "~30.0.5", "mongodb": "6.10.0", "prettier": "~3.3.3", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index ea531b966e29a..da0bfd9bea723 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -13,7 +13,7 @@ "prettier": "~3.3.3", "rimraf": "^6.0.1", "ts-patch": "^3.3.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/cron/package.json b/packages/cron/package.json index 162ac11ad2fa2..bd1d73b2553c1 100644 --- a/packages/cron/package.json +++ b/packages/cron/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@rocket.chat/tsconfig": "workspace:*", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/ddp-client/package.json b/packages/ddp-client/package.json index 4182fce1740eb..455453bd794af 100644 --- a/packages/ddp-client/package.json +++ b/packages/ddp-client/package.json @@ -9,7 +9,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "jest-websocket-mock": "~2.5.0", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "ws": "^8.18.2" }, "peerDependencies": { diff --git a/packages/e2e-crypto-core/.gitignore b/packages/e2e-crypto-core/.gitignore deleted file mode 100644 index c33dedd99a2cc..0000000000000 --- a/packages/e2e-crypto-core/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist-cjs \ No newline at end of file diff --git a/packages/e2e-crypto-core/scripts/postbuild.cjs b/packages/e2e-crypto-core/scripts/postbuild.cjs deleted file mode 100644 index 182bde160536b..0000000000000 --- a/packages/e2e-crypto-core/scripts/postbuild.cjs +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -/* Simple postbuild script: merge CJS artifacts into dist for dual package export mapping. - For now we just rename dist-cjs/index.js -> dist/index.cjs (already configured) and copy d.ts if needed. */ - -const fs = require('fs'); -const path = require('path'); - -const root = path.join(__dirname, '..'); -const cjsDir = path.join(root, 'dist-cjs'); -const distDir = path.join(root, 'dist'); - -if (!fs.existsSync(cjsDir)) process.exit(0); - -const sourceIndex = path.join(cjsDir, 'index.js'); -const targetIndex = path.join(distDir, 'index.cjs'); -if (fs.existsSync(sourceIndex)) { - fs.copyFileSync(sourceIndex, targetIndex); - console.log('[postbuild] Wrote', path.relative(root, targetIndex)); -} diff --git a/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts b/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts deleted file mode 100644 index 1269c2810542f..0000000000000 --- a/packages/e2e-crypto-core/src/__tests__/e2ee.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { E2EE } from '../index.ts'; - -class MemoryStorage implements Storage { - private data = new Map(); - clear(): void { - this.data.clear(); - } - getItem(key: string): string | null { - return this.data.has(key) ? this.data.get(key)! : null; - } - key(index: number): string | null { - return Array.from(this.data.keys())[index] ?? null; - } - removeItem(key: string): void { - this.data.delete(key); - } - setItem(key: string, value: string): void { - this.data.set(key, value); - } - get length() { - return this.data.size; - } -} - -class DeterministicCrypto implements Crypto { - private seq: number[]; - private idx = 0; - constructor(seq: number[]) { - this.seq = seq; - } - getRandomValues(array: T): T { - // naive: fill Uint32 or Uint8 - if (array instanceof Uint32Array) { - for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; - } else if (array instanceof Uint8Array) { - for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; - } - return array; - } - // Unused methods (stubs) - get [Symbol.toStringTag]() { - return 'DeterministicCrypto'; - } - randomUUID(): `${string}-${string}-${string}-${string}-${string}` { - return '00000000-0000-4000-8000-000000000000'; - } - // @ts-expect-error not implemented - subtle: SubtleCrypto; -} - -test('E2EE createRandomPassword deterministic generation with 5 words', async () => { - const storage = new MemoryStorage(); - // Inject custom word list by temporarily defining dynamic import. - // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. - // So we skip testing exact phrase (depends on wordList) and just assert shape. - const crypto = new DeterministicCrypto([1, 2, 3, 4, 5]); - const e2ee = E2EE.fromWeb(storage, crypto as any); - const pwd = await e2ee.createRandomPassword(); - assert.equal(pwd.split(' ').length, 5); - assert.equal(await e2ee.getRandomPassword(), pwd); -}); diff --git a/packages/e2e-crypto-core/tsconfig.json b/packages/e2e-crypto-core/tsconfig.json deleted file mode 100644 index a7c530af96679..0000000000000 --- a/packages/e2e-crypto-core/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "composite": false, - "strict": true, - "module": "node20", - "moduleDetection": "force", - "target": "ES2024", - "lib": ["ES2024", "ES2024.Promise", "ES2022.Error"], - "skipLibCheck": true, - "noEmit": true, - "erasableSyntaxOnly": true, - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - "noImplicitAny": true, - "noImplicitOverride": true, - "noUncheckedIndexedAccess": true, - "noUncheckedSideEffectImports": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "libReplacement": false, - "isolatedDeclarations": true - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/e2e-crypto-core/README.md b/packages/e2ee/README.md similarity index 100% rename from packages/e2e-crypto-core/README.md rename to packages/e2ee/README.md diff --git a/packages/e2e-crypto-core/package.json b/packages/e2ee/package.json similarity index 72% rename from packages/e2e-crypto-core/package.json rename to packages/e2ee/package.json index 9065d1473743c..3dfb9f05f069c 100644 --- a/packages/e2e-crypto-core/package.json +++ b/packages/e2ee/package.json @@ -1,5 +1,6 @@ { - "name": "@rocket.chat/e2e-crypto-core", + "name": "@rocket.chat/e2ee", + "private": true, "version": "0.1.0", "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", "type": "module", @@ -15,15 +16,14 @@ }, "sideEffects": false, "scripts": { - "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.json --module commonjs --outDir dist-cjs && node ./scripts/postbuild.cjs", - "clean": "rimraf dist dist-cjs", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "eslint 'src/**/*.ts'", "testunit": "node --experimental-strip-types --test" }, "devDependencies": { - "@types/node": "^24.2.1", - "rimraf": "^5.0.5", + "@types/node": "~22.16.5", "typescript": "~5.9.2" }, "license": "MIT", diff --git a/packages/e2ee/scripts/postbuild.ts b/packages/e2ee/scripts/postbuild.ts new file mode 100644 index 0000000000000..be23ae772367e --- /dev/null +++ b/packages/e2ee/scripts/postbuild.ts @@ -0,0 +1,6 @@ +import { writeFile } from 'node:fs/promises'; + +// Create the CJS module entry point +// Require the E2EE class from the ESM module + +await writeFile('./dist/index.cjs', "module.exports = require('./index.js');\n"); diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts new file mode 100644 index 0000000000000..ed4773cfd3835 --- /dev/null +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -0,0 +1,59 @@ +/// + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { E2EE } from '../index.ts'; +import { MemoryStorage } from '../storage/memoryStorage.ts'; +import { webcrypto } from 'node:crypto'; +class DeterministicCrypto implements webcrypto.Crypto { + private seq: number[]; + private idx = 0; + constructor(seq: number[], subtle: webcrypto.SubtleCrypto, CryptoKey: webcrypto.CryptoKeyConstructor) { + this.seq = seq; + // Use provided SubtleCrypto, else fall back to environment's, else a no-op placeholder. + this.subtle = subtle; + this.CryptoKey = CryptoKey; + } + CryptoKey: webcrypto.CryptoKeyConstructor; + subtle: webcrypto.SubtleCrypto; + getRandomValues(array: T): T { + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; + } else if (array instanceof Uint8Array) { + for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; + } + return array; + } + get [Symbol.toStringTag]() { + return 'DeterministicCrypto'; + } + randomUUID(): `${string}-${string}-${string}-${string}-${string}` { + const bytes = new Uint8Array(16); + this.getRandomValues(bytes); + // RFC 4122 variant & version adjustments + bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}` as `${string}-${string}-${string}-${string}-${string}`; + } +} + +const fromLocalStorage = (storage: Pick<(typeof globalThis)['localStorage'], 'getItem' | 'setItem' | 'removeItem'>) => { + return { + load: (keyName: string) => Promise.resolve(storage.getItem(keyName)), + store: (keyName: string, value: string) => Promise.resolve(storage.setItem(keyName, value)), + remove: (keyName: string) => Promise.resolve(storage.removeItem(keyName)), + }; +}; + +test('E2EE createRandomPassword deterministic generation with 5 words', async () => { + const storage = new MemoryStorage(); + // Inject custom word list by temporarily defining dynamic import. + // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. + // So we skip testing exact phrase (depends on wordList) and just assert shape. + const deterministicCrypto = new DeterministicCrypto([1, 2, 3, 4, 5], webcrypto.subtle, webcrypto.CryptoKey); + const e2ee = new E2EE(fromLocalStorage(storage), deterministicCrypto); + const pwd = await e2ee.createRandomPassword(); + assert.equal(pwd.split(' ').length, 5); + assert.equal(await e2ee.getRandomPassword(), pwd); +}); diff --git a/packages/e2e-crypto-core/src/index.ts b/packages/e2ee/src/index.ts similarity index 80% rename from packages/e2e-crypto-core/src/index.ts rename to packages/e2ee/src/index.ts index f1ccfd2a15046..29fee26aadbf5 100644 --- a/packages/e2e-crypto-core/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -22,19 +22,6 @@ export class E2EE { this.#random = random; } - static fromWeb(storage: Storage, crypto: Crypto): E2EE { - return new E2EE( - { - load: (keyName) => Promise.resolve(storage.getItem(keyName)), - store: (keyName, value) => Promise.resolve(storage.setItem(keyName, value)), - remove: (keyName) => Promise.resolve(storage.removeItem(keyName)), - }, - { - getRandomValues: (array) => crypto.getRandomValues(array), - }, - ); - } - async getKeysFromLocalStorage(): Promise { return { public_key: await this.#storage.load('public_key'), diff --git a/packages/e2ee/src/storage/localStorage.ts b/packages/e2ee/src/storage/localStorage.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/e2ee/src/storage/memoryStorage.ts b/packages/e2ee/src/storage/memoryStorage.ts new file mode 100644 index 0000000000000..aa59eaf379826 --- /dev/null +++ b/packages/e2ee/src/storage/memoryStorage.ts @@ -0,0 +1,21 @@ +export class MemoryStorage { + #data = new Map(); + clear(): void { + this.#data.clear(); + } + getItem(key: string): string | null { + return this.#data.has(key) ? this.#data.get(key)! : null; + } + key(index: number): string | null { + return Array.from(this.#data.keys())[index] ?? null; + } + removeItem(key: string): void { + this.#data.delete(key); + } + setItem(key: string, value: string): void { + this.#data.set(key, value); + } + get length(): number { + return this.#data.size; + } +} diff --git a/packages/e2e-crypto-core/src/wordList.ts b/packages/e2ee/src/wordList.ts similarity index 100% rename from packages/e2e-crypto-core/src/wordList.ts rename to packages/e2ee/src/wordList.ts diff --git a/packages/e2e-crypto-core/tsconfig.build.json b/packages/e2ee/tsconfig.build.json similarity index 100% rename from packages/e2e-crypto-core/tsconfig.build.json rename to packages/e2ee/tsconfig.build.json diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json new file mode 100644 index 0000000000000..821712edf70cf --- /dev/null +++ b/packages/e2ee/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "composite": false, + "strict": true, + "module": "node20", + "moduleDetection": "force", + "target": "ES2024", + "lib": ["ES2024.Promise", "ES2022.Error", "ES2023.Array", "ES2015"], + "skipLibCheck": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "libReplacement": false, + "isolatedDeclarations": true, + "types": [] + }, + "include": ["src"], + "exclude": [] +} diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index c66f01e149e3e..fe10b2f939bfe 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -36,7 +36,7 @@ "react.js" ], "devDependencies": { - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/favicon/package.json b/packages/favicon/package.json index 5564390e8c0b5..3abcb10d48d7b 100644 --- a/packages/favicon/package.json +++ b/packages/favicon/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/freeswitch/package.json b/packages/freeswitch/package.json index 9573fb1cbda6c..cdf276195a9cc 100644 --- a/packages/freeswitch/package.json +++ b/packages/freeswitch/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index 2e67bd99a2124..f70913d32ce45 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -94,7 +94,7 @@ "rimraf": "^6.0.1", "storybook": "^8.6.14", "storybook-dark-mode": "^4.0.2", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "peerDependencies": { diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 0b10070aa40ea..bd23971768757 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -78,7 +78,7 @@ "react-i18next": "~13.2.2", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "peerDependencies": { diff --git a/packages/http-router/package.json b/packages/http-router/package.json index 3b6c781c49e3b..6338c5a08fa29 100644 --- a/packages/http-router/package.json +++ b/packages/http-router/package.json @@ -13,7 +13,7 @@ "jest": "~30.0.5", "supertest": "^7.1.1", "ts-jest": "~29.4.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "rm -rf dist && tsc", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index c21905633176f..3e8845b5d1a2a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -9,7 +9,7 @@ "eslint": "~8.45.0", "i18next": "~23.4.9", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "node ./src/scripts/build.mjs && run .:build:esm && run .:build:cjs", diff --git a/packages/instance-status/package.json b/packages/instance-status/package.json index 2635868d4f670..ce9fe8320fefd 100644 --- a/packages/instance-status/package.json +++ b/packages/instance-status/package.json @@ -8,7 +8,7 @@ "eslint": "~8.45.0", "mongodb": "6.10.0", "prettier": "~3.3.3", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/jest-presets/package.json b/packages/jest-presets/package.json index bae5d33b266a3..45d5731765d58 100644 --- a/packages/jest-presets/package.json +++ b/packages/jest-presets/package.json @@ -30,7 +30,7 @@ "@types/uuid": "^10.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/jest-presets/src/client/jest-setup.ts b/packages/jest-presets/src/client/jest-setup.ts index 87fe139acb291..4f0d7e39f2d6a 100644 --- a/packages/jest-presets/src/client/jest-setup.ts +++ b/packages/jest-presets/src/client/jest-setup.ts @@ -65,5 +65,5 @@ globalThis.IntersectionObserver = class IntersectionObserver { } }; -globalThis.TextEncoder = TextEncoder; +globalThis.TextEncoder = TextEncoder as any; globalThis.TextDecoder = TextDecoder as any; diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 56324f6e00e1a..bea01de76b750 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "rm -rf dist && tsc", diff --git a/packages/livechat/package.json b/packages/livechat/package.json index bee2dca316fc4..962fdd9cfa37c 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -87,7 +87,7 @@ "stylelint-order": "^6.0.4", "svg-loader": "^0.0.2", "terser-webpack-plugin": "~4.2.3", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "url-loader": "^4.1.1", "webpack": "~5.99.9", "webpack-cli": "~5.1.4", diff --git a/packages/log-format/package.json b/packages/log-format/package.json index 440dd6909cade..be8daf08bcbdc 100644 --- a/packages/log-format/package.json +++ b/packages/log-format/package.json @@ -6,7 +6,7 @@ "@types/chalk": "^2.2.4", "@types/ejson": "^2.2.2", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/logger/package.json b/packages/logger/package.json index 2c78e549d9642..efc74bf16fd2f 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/message-parser/package.json b/packages/message-parser/package.json index 230529047ae30..0512d58c9bce4 100644 --- a/packages/message-parser/package.json +++ b/packages/message-parser/package.json @@ -56,7 +56,7 @@ "@rocket.chat/peggy-loader": "workspace:~", "@rocket.chat/prettier-config": "~0.31.25", "@types/jest": "~30.0.0", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "@typescript-eslint/parser": "~5.58.0", "babel-loader": "~9.2.1", "eslint": "~8.45.0", @@ -68,7 +68,7 @@ "rimraf": "^6.0.1", "ts-loader": "~9.5.2", "typedoc": "~0.28.5", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9", "webpack-cli": "~5.1.4" }, diff --git a/packages/mock-providers/package.json b/packages/mock-providers/package.json index 59d2c8948f98c..49f553ed6597e 100644 --- a/packages/mock-providers/package.json +++ b/packages/mock-providers/package.json @@ -26,7 +26,7 @@ "react": "~18.3.1", "react-dom": "~18.3.1", "storybook": "^8.6.14", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@tanstack/react-query": "*", diff --git a/packages/model-typings/package.json b/packages/model-typings/package.json index c3b8357ec7f6e..11d0c185aa021 100644 --- a/packages/model-typings/package.json +++ b/packages/model-typings/package.json @@ -6,7 +6,7 @@ "@types/node-rsa": "^1.1.4", "eslint": "~8.45.0", "mongodb": "6.10.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/models/package.json b/packages/models/package.json index 6c836bfaa1182..ee39554174c6e 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -9,7 +9,7 @@ "@types/node-rsa": "^1.1.4", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "dependencies": { "@rocket.chat/model-typings": "workspace:~", diff --git a/packages/mongo-adapter/package.json b/packages/mongo-adapter/package.json index dc183ec4b4c23..c4abd73ae8d49 100644 --- a/packages/mongo-adapter/package.json +++ b/packages/mongo-adapter/package.json @@ -20,7 +20,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "mongodb": "6.10.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "mongodb": "6.10.0" diff --git a/packages/node-poplib/package.json b/packages/node-poplib/package.json index 2e6e9c0dbd42d..4a03edbfbc64e 100644 --- a/packages/node-poplib/package.json +++ b/packages/node-poplib/package.json @@ -4,7 +4,7 @@ "private": true, "devDependencies": { "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "main": "./src/index.js", "typings": "./dist/index.d.ts", diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index 6432c2eb5775c..2dbe7bd594772 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -9,7 +9,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/password-policies/package.json b/packages/password-policies/package.json index f7fa40cc14c47..2bd6b43b380b4 100644 --- a/packages/password-policies/package.json +++ b/packages/password-policies/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "rm -rf dist && tsc", diff --git a/packages/patch-injection/package.json b/packages/patch-injection/package.json index 4ab402eeff32f..83b75dc0f05ea 100644 --- a/packages/patch-injection/package.json +++ b/packages/patch-injection/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "rm -rf dist && tsc", diff --git a/packages/peggy-loader/package.json b/packages/peggy-loader/package.json index 7313b1e12a2d1..c85c0e1a70c0d 100644 --- a/packages/peggy-loader/package.json +++ b/packages/peggy-loader/package.json @@ -44,13 +44,13 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:~", "@rocket.chat/prettier-config": "~0.31.25", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "eslint": "~8.45.0", "npm-run-all": "^4.1.5", "peggy": "4.1.1", "prettier": "~3.3.3", "rimraf": "^6.0.1", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "volta": { diff --git a/packages/random/package.json b/packages/random/package.json index 0e4da1ea67775..7eada0450fcd3 100644 --- a/packages/random/package.json +++ b/packages/random/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/parser": "~5.60.1", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/release-action/package.json b/packages/release-action/package.json index c1c34c284d0fd..cf1f1afbf2bf4 100644 --- a/packages/release-action/package.json +++ b/packages/release-action/package.json @@ -10,8 +10,8 @@ "main": "dist/index.js", "packageManager": "yarn@4.9.2", "devDependencies": { - "@types/node": "~22.16.1", - "typescript": "~5.8.3" + "@types/node": "~22.16.5", + "typescript": "~5.9.2" }, "dependencies": { "@actions/core": "^1.11.1", diff --git a/packages/release-changelog/package.json b/packages/release-changelog/package.json index ecefa784d7d7b..e3fea8d63e4d9 100644 --- a/packages/release-changelog/package.json +++ b/packages/release-changelog/package.json @@ -10,9 +10,9 @@ "devDependencies": { "@changesets/types": "^6.0.0", "@rocket.chat/eslint-config": "workspace:^", - "@types/node": "~22.16.1", + "@types/node": "~22.16.5", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "dependencies": { "dataloader": "^2.2.3", diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 021bdfef61a92..eeeae6b400963 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -8,7 +8,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "mongodb": "6.10.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "build": "rm -rf dist && tsc", diff --git a/packages/server-cloud-communication/package.json b/packages/server-cloud-communication/package.json index 0f104f548e5b4..092ff89cf8cd9 100644 --- a/packages/server-cloud-communication/package.json +++ b/packages/server-cloud-communication/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@rocket.chat/license": "workspace:^", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/server-fetch/package.json b/packages/server-fetch/package.json index 7e0e76d8d815a..7bb53a8593657 100644 --- a/packages/server-fetch/package.json +++ b/packages/server-fetch/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@types/node-fetch": "~2.6.12", "eslint": "~8.45.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/sha256/package.json b/packages/sha256/package.json index 8c51cb0944c4b..6d158dd6c1a36 100644 --- a/packages/sha256/package.json +++ b/packages/sha256/package.json @@ -23,7 +23,7 @@ "@typescript-eslint/parser": "~5.60.1", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "volta": { "extends": "../../package.json" diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json index 2844732361c4f..ad7db5f0ba2cf 100644 --- a/packages/storybook-config/package.json +++ b/packages/storybook-config/package.json @@ -29,7 +29,7 @@ "eslint": "~8.45.0", "react": "~18.3.1", "react-dom": "~18.3.1", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@rocket.chat/fuselage": "*", diff --git a/packages/tools/package.json b/packages/tools/package.json index 9dbbd7771e0a4..caa942ab33493 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 7791d82425da9..77e240627baf2 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -7,7 +7,7 @@ "eslint": "~8.45.0", "jest": "~30.0.5", "ts-jest": "~29.4.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json index baea040850ed6..c1f834596e97d 100644 --- a/packages/ui-avatar/package.json +++ b/packages/ui-avatar/package.json @@ -22,7 +22,7 @@ "react": "~18.3.1", "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index 447140d556c2d..ca6c26b2e742e 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -54,7 +54,7 @@ "react-hook-form": "~7.45.4", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@react-aria/toolbar": "*", diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index afdbf5866be1d..a5cd00c2cfee4 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -51,7 +51,7 @@ "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "peerDependencies": { diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index 4add729ae864c..b8cba15fee64e 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -18,7 +18,7 @@ "i18next": "~23.4.9", "mongodb": "6.10.0", "react": "~18.3.1", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@rocket.chat/core-typings": "workspace:^", diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json index dec226e3d7797..757f968985d11 100644 --- a/packages/ui-kit/package.json +++ b/packages/ui-kit/package.json @@ -51,7 +51,7 @@ "rimraf": "~6.0.1", "ts-jest": "~29.4.0", "ts-patch": "^3.3.0", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "dependencies": { "typia": "~9.3.1" diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json index 8f5743c257d2f..962810a803aa1 100644 --- a/packages/ui-video-conf/package.json +++ b/packages/ui-video-conf/package.json @@ -56,7 +56,7 @@ "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "typescript": "~5.8.3", + "typescript": "~5.9.2", "webpack": "~5.99.9" }, "peerDependencies": { diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index 0083962895cf7..d41dce9466d6d 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -71,7 +71,7 @@ "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@rocket.chat/css-in-js": "*", diff --git a/packages/web-ui-registration/package.json b/packages/web-ui-registration/package.json index b23bad7e6aabc..73eab3afa0061 100644 --- a/packages/web-ui-registration/package.json +++ b/packages/web-ui-registration/package.json @@ -58,7 +58,7 @@ "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", "storybook-dark-mode": "^4.0.2", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "peerDependencies": { "@rocket.chat/layout": "*", diff --git a/yarn.lock b/yarn.lock index e64503500bbab..5debe3c8c6d4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2404,13 +2404,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10/c08f1dd7dd18fbb60bdd0d85820656d1374dd898af9be7f82cb00451313402a22d5e30569c150315b4385907cdbca78c22389b2a72ab78883b3173be317620cc - languageName: node - linkType: hard - "@eslint-community/regexpp@npm:^4.4.0": version: 4.5.1 resolution: "@eslint-community/regexpp@npm:4.5.1" @@ -2418,33 +2411,6 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.21.0": - version: 0.21.0 - resolution: "@eslint/config-array@npm:0.21.0" - dependencies: - "@eslint/object-schema": "npm:^2.1.6" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10/f5a499e074ecf4b4a5efdca655418a12079d024b77d02fd35868eeb717c5bfdd8e32c6e8e1dd125330233a878026edda8062b13b4310169ba5bfee9623a67aa0 - languageName: node - linkType: hard - -"@eslint/config-helpers@npm:^0.3.1": - version: 0.3.1 - resolution: "@eslint/config-helpers@npm:0.3.1" - checksum: 10/fc1a90ef6180aa4b5187cee04cfc566abb2a32b77ca3e7eeb4312c7388f6898221adaf8451d9ddb22e0b8860d900fefb1eb1435e4f32f8d8732de87f14605f8f - languageName: node - linkType: hard - -"@eslint/core@npm:^0.15.2": - version: 0.15.2 - resolution: "@eslint/core@npm:0.15.2" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10/41d6273bbc6897cca34a2ca4e80a24bf6f1d43519456ebaa3c38f187da2d9e06f442c64f6e2a2813f055dce35e5cea33a21d0ac3b5b0830b7165641c640faf5d - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.0": version: 2.1.0 resolution: "@eslint/eslintrc@npm:2.1.0" @@ -2462,23 +2428,6 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.1": - version: 3.3.1 - resolution: "@eslint/eslintrc@npm:3.3.1" - dependencies: - ajv: "npm:^6.12.4" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.0" - minimatch: "npm:^3.1.2" - strip-json-comments: "npm:^3.1.1" - checksum: 10/cc240addbab3c5fceaa65b2c8d5d4fd77ddbbf472c2f74f0270b9d33263dc9116840b6099c46b64c9680301146250439b044ed79278a1bcc557da412a4e3c1bb - languageName: node - linkType: hard - "@eslint/js@npm:8.44.0": version: 8.44.0 resolution: "@eslint/js@npm:8.44.0" @@ -2486,30 +2435,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.33.0": - version: 9.33.0 - resolution: "@eslint/js@npm:9.33.0" - checksum: 10/415031162eeee4ed67457585f3a3f3442521e75dd352932582683452c393d837da81cf9726a2cf097b444119ae2e405951e6b5d84546f67b6370fc36f27d8321 - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^2.1.6": - version: 2.1.6 - resolution: "@eslint/object-schema@npm:2.1.6" - checksum: 10/266085c8d3fa6cd99457fb6350dffb8ee39db9c6baf28dc2b86576657373c92a568aec4bae7d142978e798b74c271696672e103202d47a0c148da39154351ed6 - languageName: node - linkType: hard - -"@eslint/plugin-kit@npm:^0.3.5": - version: 0.3.5 - resolution: "@eslint/plugin-kit@npm:0.3.5" - dependencies: - "@eslint/core": "npm:^0.15.2" - levn: "npm:^0.4.1" - checksum: 10/b8552d79c3091446b07d8b87a9a8ccb8cdee4d933c0ed46b8f61029c3382246fec8d04ea7d1e61656d9275263205ccaa40019fd7581bbce897eca3eda42d5dad - languageName: node - linkType: hard - "@faker-js/faker@npm:~8.0.2": version: 8.0.2 resolution: "@faker-js/faker@npm:8.0.2" @@ -2698,23 +2623,6 @@ __metadata: languageName: node linkType: hard -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 10/270d936be483ab5921702623bc74ce394bf12abbf57d9145a69e8a0d1c87eb1c768bd2d93af16c5705041e257e6d9cc7529311f63a1349f3678abc776fc28523 - languageName: node - linkType: hard - -"@humanfs/node@npm:^0.16.6": - version: 0.16.6 - resolution: "@humanfs/node@npm:0.16.6" - dependencies: - "@humanfs/core": "npm:^0.19.1" - "@humanwhocodes/retry": "npm:^0.3.0" - checksum: 10/6d43c6727463772d05610aa05c83dab2bfbe78291022ee7a92cb50999910b8c720c76cc312822e2dea2b497aa1b3fef5fe9f68803fc45c9d4ed105874a65e339 - languageName: node - linkType: hard - "@humanwhocodes/config-array@npm:^0.11.10": version: 0.11.10 resolution: "@humanwhocodes/config-array@npm:0.11.10" @@ -2740,20 +2648,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.3.0": - version: 0.3.1 - resolution: "@humanwhocodes/retry@npm:0.3.1" - checksum: 10/eb457f699529de7f07649679ec9e0353055eebe443c2efe71c6dd950258892475a038e13c6a8c5e13ed1fb538cdd0a8794faa96b24b6ffc4c87fb1fc9f70ad7f - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.4.2": - version: 0.4.3 - resolution: "@humanwhocodes/retry@npm:0.4.3" - checksum: 10/0b32cfd362bea7a30fbf80bb38dcaf77fee9c2cae477ee80b460871d03590110ac9c77d654f04ec5beaf71b6f6a89851bdf6c1e34ccdf2f686bd86fcd97d9e61 - languageName: node - linkType: hard - "@img/sharp-darwin-arm64@npm:0.33.5": version: 0.33.5 resolution: "@img/sharp-darwin-arm64@npm:0.33.5" @@ -7018,7 +6912,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/bcrypt": "npm:^5.0.2" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" bcrypt: "npm:^5.1.1" @@ -7034,7 +6928,7 @@ __metadata: polka: "npm:^0.5.2" prometheus-gc-stats: "npm:^1.1.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" uuid: "npm:^11.0.3" languageName: unknown linkType: soft @@ -7044,7 +6938,7 @@ __metadata: resolution: "@rocket.chat/account-utils@workspace:packages/account-utils" dependencies: eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7060,7 +6954,7 @@ __metadata: human-interval: "npm:^2.0.1" moment-timezone: "npm:~0.5.48" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7081,7 +6975,7 @@ __metadata: query-string: "npm:^7.1.3" split-on-first: "npm:^3.0.0" strict-uri-encode: "npm:^2.0.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7096,7 +6990,7 @@ __metadata: "@types/adm-zip": "npm:^0.5.7" "@types/debug": "npm:^4.1.12" "@types/lodash.clonedeep": "npm:^4.5.9" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/semver": "npm:^7.5.8" "@types/stack-trace": "npm:0.0.33" "@types/uuid": "npm:~10.0.0" @@ -7119,7 +7013,7 @@ __metadata: tap-bark: "npm:^1.0.0" ts-node: "npm:^6.2.0" typedoc: "npm:~0.28.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" uglify-es: "npm:^3.3.10" uuid: "npm:~11.0.5" languageName: unknown @@ -7134,7 +7028,7 @@ __metadata: "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7154,7 +7048,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -7169,7 +7063,7 @@ __metadata: polka: "npm:^0.5.2" prometheus-gc-stats: "npm:^1.1.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7186,7 +7080,7 @@ __metadata: "@typescript-eslint/parser": "npm:~5.60.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7197,7 +7091,7 @@ __metadata: cheerio: "npm:1.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7225,7 +7119,7 @@ __metadata: jest: "npm:~30.0.5" mongodb: "npm:6.10.0" prettier: "npm:~3.3.3" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7245,7 +7139,7 @@ __metadata: prettier: "npm:~3.3.3" rimraf: "npm:^6.0.1" ts-patch: "npm:^3.3.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" typia: "npm:~9.3.1" languageName: unknown linkType: soft @@ -7261,7 +7155,7 @@ __metadata: "@rocket.chat/tsconfig": "workspace:*" eslint: "npm:~8.45.0" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7301,7 +7195,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" jest-websocket-mock: "npm:~2.5.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" ws: "npm:^8.18.2" peerDependencies: "@rocket.chat/emitter": "*" @@ -7327,7 +7221,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@types/ejson": "npm:^2.2.2" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" "@types/underscore": "npm:^1.13.0" @@ -7349,21 +7243,18 @@ __metadata: prometheus-gc-stats: "npm:^1.1.0" sharp: "npm:^0.33.5" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" underscore: "npm:^1.13.7" uuid: "npm:^11.0.3" ws: "npm:^8.18.2" languageName: unknown linkType: soft -"@rocket.chat/e2e-crypto-core@workspace:^, @rocket.chat/e2e-crypto-core@workspace:packages/e2e-crypto-core": +"@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": version: 0.0.0-use.local - resolution: "@rocket.chat/e2e-crypto-core@workspace:packages/e2e-crypto-core" + resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: - "@types/node": "npm:^24.2.1" - eslint: "npm:^9.10.0" - jest: "npm:~30.0.5" - rimraf: "npm:^5.0.5" + "@types/node": "npm:~22.16.5" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7393,7 +7284,7 @@ __metadata: eslint-plugin-jsx-a11y: "npm:^6.10.2" eslint-plugin-prettier: "npm:~5.2.6" prettier: "npm:~3.3.3" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7402,7 +7293,7 @@ __metadata: resolution: "@rocket.chat/favicon@workspace:packages/favicon" dependencies: eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7419,7 +7310,7 @@ __metadata: esl: "github:pierre-lehnen-rc/esl" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7538,7 +7429,7 @@ __metadata: rimraf: "npm:^6.0.1" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@rocket.chat/apps-engine": "workspace:^" @@ -7641,7 +7532,7 @@ __metadata: react-stately: "npm:~3.17.0" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@rocket.chat/core-typings": "workspace:^" @@ -7677,7 +7568,7 @@ __metadata: qs: "npm:^6.14.0" supertest: "npm:^7.1.1" ts-jest: "npm:~29.4.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7691,7 +7582,7 @@ __metadata: eslint: "npm:~8.45.0" i18next: "npm:~23.4.9" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@rocket.chat/tools": "workspace:~" i18next: "*" @@ -7716,7 +7607,7 @@ __metadata: eslint: "npm:~8.45.0" mongodb: "npm:6.10.0" prettier: "npm:~3.3.3" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7738,7 +7629,7 @@ __metadata: jest-axe: "npm:~10.0.0" jest-environment-jsdom: "npm:~30.0.2" jest-environment-node: "npm:~30.0.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" uuid: "npm:~11.0.5" languageName: unknown linkType: soft @@ -7753,7 +7644,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" jose: "npm:^4.15.9" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7785,7 +7676,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" jest-websocket-mock: "npm:~2.5.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7876,7 +7767,7 @@ __metadata: stylelint-order: "npm:^6.0.4" svg-loader: "npm:^0.0.2" terser-webpack-plugin: "npm:~4.2.3" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" url-loader: "npm:^4.1.1" webpack: "npm:~5.99.9" webpack-cli: "npm:~5.1.4" @@ -7897,7 +7788,7 @@ __metadata: chalk: "npm:^4.1.2" ejson: "npm:^2.2.3" eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7908,7 +7799,7 @@ __metadata: "@rocket.chat/emitter": "npm:~0.31.25" eslint: "npm:~8.45.0" pino: "npm:^8.21.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7944,7 +7835,7 @@ __metadata: "@rocket.chat/peggy-loader": "workspace:~" "@rocket.chat/prettier-config": "npm:~0.31.25" "@types/jest": "npm:~30.0.0" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@typescript-eslint/parser": "npm:~5.58.0" babel-loader: "npm:~9.2.1" eslint: "npm:~8.45.0" @@ -7957,7 +7848,7 @@ __metadata: tldts: "npm:~6.1.86" ts-loader: "npm:~9.5.2" typedoc: "npm:~0.28.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" webpack-cli: "npm:~5.1.4" languageName: unknown @@ -8003,7 +7894,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/cron": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" - "@rocket.chat/e2e-crypto-core": "workspace:^" + "@rocket.chat/e2ee": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" @@ -8121,7 +8012,7 @@ __metadata: "@types/meteor-collection-hooks": "npm:^0.8.9" "@types/mkdirp": "npm:^1.0.2" "@types/mocha": "github:whitecolor/mocha-types" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/node-rsa": "npm:^1.1.4" "@types/nodemailer": "npm:^6.4.17" "@types/oauth2-server": "npm:^3.0.18" @@ -8344,7 +8235,7 @@ __metadata: ts-node: "npm:^10.9.2" twilio: "npm:^5.4.2" twit: "npm:^2.2.11" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" typia: "npm:~9.3.1" ua-parser-js: "npm:^1.0.40" underscore: "npm:^1.13.7" @@ -8386,7 +8277,7 @@ __metadata: react-dom: "npm:~18.3.1" react-i18next: "npm:~13.2.2" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@tanstack/react-query": "*" react: "*" @@ -8401,7 +8292,7 @@ __metadata: "@types/node-rsa": "npm:^1.1.4" eslint: "npm:~8.45.0" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8423,7 +8314,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" node-rsa: "npm:^1.1.1" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8435,7 +8326,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: mongodb: 6.10.0 languageName: unknown @@ -8457,7 +8348,7 @@ __metadata: "@rocket.chat/tsconfig": "workspace:*" "@types/chai": "npm:~4.3.20" "@types/ejson": "npm:^2.2.2" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/sinon": "npm:^10.0.20" chai: "npm:^4.5.0" ejson: "npm:^2.2.3" @@ -8466,7 +8357,7 @@ __metadata: moleculer: "npm:^0.14.35" pino: "npm:^8.21.0" sinon: "npm:^19.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8482,7 +8373,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8498,7 +8389,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8521,7 +8412,7 @@ __metadata: "@rocket.chat/tools": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" date-fns: "npm:~4.1.0" ejson: "npm:^2.2.3" emoji-toolkit: "npm:^7.0.1" @@ -8534,7 +8425,7 @@ __metadata: mongo-message-queue: "npm:^1.1.0" mongodb: "npm:6.10.0" pino: "npm:^8.21.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8558,7 +8449,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/i18next-sprintf-postprocessor": "npm:^0.2.3" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" "@types/react": "npm:~18.3.23" @@ -8580,7 +8471,7 @@ __metadata: prometheus-gc-stats: "npm:^1.1.0" react: "npm:~18.3.1" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8614,7 +8505,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8627,7 +8518,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8660,7 +8551,7 @@ __metadata: react: "npm:~18.3.1" react-dom: "npm:~18.3.1" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8670,13 +8561,13 @@ __metadata: dependencies: "@rocket.chat/eslint-config": "workspace:~" "@rocket.chat/prettier-config": "npm:~0.31.25" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" eslint: "npm:~8.45.0" npm-run-all: "npm:^4.1.5" peggy: "npm:4.1.1" prettier: "npm:~3.3.3" rimraf: "npm:^6.0.1" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: peggy: "*" @@ -8689,7 +8580,7 @@ __metadata: resolution: "@rocket.chat/poplib@workspace:packages/node-poplib" dependencies: eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8709,7 +8600,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -8724,7 +8615,7 @@ __metadata: polka: "npm:^0.5.2" prometheus-gc-stats: "npm:^1.1.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8741,12 +8632,12 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" babel-jest: "npm:~30.0.5" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8774,7 +8665,7 @@ __metadata: "@rocket.chat/omnichannel-services": "workspace:^" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -8792,7 +8683,7 @@ __metadata: polka: "npm:^0.5.2" prometheus-gc-stats: "npm:^1.1.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8809,7 +8700,7 @@ __metadata: "@typescript-eslint/parser": "npm:~5.60.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8823,13 +8714,13 @@ __metadata: "@octokit/core": "npm:^5.0.1" "@octokit/plugin-throttling": "npm:^6.1.0" "@rocket.chat/eslint-config": "workspace:^" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" eslint: "npm:~8.45.0" mdast-util-to-string: "npm:2.0.0" remark-parse: "npm:9.0.0" remark-stringify: "npm:9.0.1" semver: "npm:^7.6.3" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" unified: "npm:9.2.2" languageName: unknown linkType: soft @@ -8840,11 +8731,11 @@ __metadata: dependencies: "@changesets/types": "npm:^6.0.0" "@rocket.chat/eslint-config": "workspace:^" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" dataloader: "npm:^2.2.3" eslint: "npm:~8.45.0" node-fetch: "npm:^2.7.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8863,7 +8754,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" mongodb: "npm:6.10.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8873,7 +8764,7 @@ __metadata: dependencies: "@rocket.chat/license": "workspace:^" eslint: "npm:~8.45.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8888,7 +8779,7 @@ __metadata: https-proxy-agent: "npm:^7.0.6" node-fetch: "npm:2.7.0" proxy-from-env: "npm:^1.1.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8905,7 +8796,7 @@ __metadata: "@typescript-eslint/parser": "npm:~5.60.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8936,7 +8827,7 @@ __metadata: react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@rocket.chat/fuselage": "*" @@ -8963,7 +8854,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/bcrypt": "npm:^5.0.2" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -8978,7 +8869,7 @@ __metadata: polka: "npm:^0.5.2" prometheus-gc-stats: "npm:^1.1.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -9019,7 +8910,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" moment-timezone: "npm:^0.5.48" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -9034,7 +8925,7 @@ __metadata: eslint: "npm:~8.45.0" jest: "npm:~30.0.5" ts-jest: "npm:~29.4.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -9067,7 +8958,7 @@ __metadata: react: "npm:~18.3.1" react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@rocket.chat/fuselage": "*" "@rocket.chat/ui-contexts": "workspace:^" @@ -9116,7 +9007,7 @@ __metadata: react-hook-form: "npm:~7.45.4" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@react-aria/toolbar": "*" "@rocket.chat/css-in-js": "*" @@ -9165,7 +9056,7 @@ __metadata: react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@react-aria/toolbar": "*" @@ -9196,7 +9087,7 @@ __metadata: i18next: "npm:~23.4.9" mongodb: "npm:6.10.0" react: "npm:~18.3.1" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/ddp-client": "workspace:^" @@ -9228,7 +9119,7 @@ __metadata: rimraf: "npm:~6.0.1" ts-jest: "npm:~29.4.0" ts-patch: "npm:^3.3.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" typia: "npm:~9.3.1" peerDependencies: "@rocket.chat/icons": "*" @@ -9258,7 +9149,7 @@ __metadata: react-docgen-typescript-plugin: "npm:~1.0.8" react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@rocket.chat/css-in-js": "*" @@ -9309,7 +9200,7 @@ __metadata: react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: "@rocket.chat/css-in-js": "*" @@ -9378,7 +9269,7 @@ __metadata: react-virtuoso: "npm:^4.12.0" sip.js: "npm:^0.21.2" storybook: "npm:^8.6.14" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@rocket.chat/css-in-js": "*" "@rocket.chat/fuselage": "*" @@ -9439,7 +9330,7 @@ __metadata: react-split-pane: "npm:^0.1.92" react-virtuoso: "npm:^4.12.0" reactflow: "npm:^11.11.4" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" vite: "npm:^6.2.4" languageName: unknown linkType: soft @@ -9490,7 +9381,7 @@ __metadata: react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 @@ -11079,15 +10970,6 @@ __metadata: languageName: node linkType: hard -"@types/chart.js@npm:^2.9.41": - version: 2.9.41 - resolution: "@types/chart.js@npm:2.9.41" - dependencies: - moment: "npm:^2.10.2" - checksum: 10/302b251633bb469410c0a8bd3e9b30fdc08c14f9d92998095ae3849a1c3069f61e70df530e6598e6416b3abd13d15dd4eb4c45914300a679ce4c8dee21847301 - languageName: node - linkType: hard - "@types/codemirror@npm:~5.60.16": version: 5.60.16 resolution: "@types/codemirror@npm:5.60.16" @@ -11755,13 +11637,6 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:^4.0.9": - version: 4.0.9 - resolution: "@types/js-yaml@npm:4.0.9" - checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6 - languageName: node - linkType: hard - "@types/jsdom-global@npm:^3.0.7": version: 3.0.7 resolution: "@types/jsdom-global@npm:3.0.7" @@ -12055,7 +11930,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=0.0.2, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18, @types/node@npm:>=18.0.0, @types/node@npm:>=4.0.0, @types/node@npm:~22.16.1": +"@types/node@npm:*, @types/node@npm:>=0.0.2, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18, @types/node@npm:>=18.0.0, @types/node@npm:>=4.0.0": version: 22.16.1 resolution: "@types/node@npm:22.16.1" dependencies: @@ -12078,21 +11953,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^24.2.1": - version: 24.2.1 - resolution: "@types/node@npm:24.2.1" - dependencies: - undici-types: "npm:~7.10.0" - checksum: 10/cdfa7b30b2f1de71c4cf0b66ee05c71c8f39819a817bb78b4ba10545d4ca1ea9988a3614885806e90401e92938aa08e08489b1b920ba5c1406a028295019734f - languageName: node - linkType: hard - -"@types/node@npm:~22.14.1": - version: 22.14.1 - resolution: "@types/node@npm:22.14.1" +"@types/node@npm:~22.16.5": + version: 22.16.5 + resolution: "@types/node@npm:22.16.5" dependencies: undici-types: "npm:~6.21.0" - checksum: 10/561b1ad98ef5176d6da856ffbbe494f16655149f6a7d561de0423c8784910c81267d7d6459f59d68a97b3cbae9b5996b3b5dfe64f4de3de2239d295dcf4a4dcc + checksum: 10/ba45b5c9113cbc5edb12960fcfe7e80db2c998af5c1931264240695b27d756570d92462150b95781bd67a03aa82111cc970ab0f4504eb99213edff8bf425354e languageName: node linkType: hard @@ -13626,15 +13492,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.15.0": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" - bin: - acorn: bin/acorn - checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 - languageName: node - linkType: hard - "add-px-to-style@npm:1.0.0": version: 1.0.0 resolution: "add-px-to-style@npm:1.0.0" @@ -19518,16 +19375,6 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10/e8e611701f65375e034c62123946e628894f0b54aa8cb11abe224816389abe5cd74cf16b62b72baa36504f22d1a958b9b8b0169b82397fe2e7997674c0d09b06 - languageName: node - linkType: hard - "eslint-visitor-keys@npm:^2.1.0": version: 2.1.0 resolution: "eslint-visitor-keys@npm:2.1.0" @@ -19542,13 +19389,6 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10/3ee00fc6a7002d4b0ffd9dc99e13a6a7882c557329e6c25ab254220d71e5c9c4f89dca4695352949ea678eb1f3ba912a18ef8aac0a7fe094196fd92f441bfce2 - languageName: node - linkType: hard - "eslint4b-prebuilt@npm:^6.7.2": version: 6.7.2 resolution: "eslint4b-prebuilt@npm:6.7.2" @@ -19556,56 +19396,6 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.10.0": - version: 9.33.0 - resolution: "eslint@npm:9.33.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.0" - "@eslint/config-helpers": "npm:^0.3.1" - "@eslint/core": "npm:^0.15.2" - "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.33.0" - "@eslint/plugin-kit": "npm:^0.3.5" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.2" - "@types/estree": "npm:^1.0.6" - "@types/json-schema": "npm:^7.0.15" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.4.0" - eslint-visitor-keys: "npm:^4.2.1" - espree: "npm:^10.4.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10/3857f17460c8a245e3dd2d23b892ce844dc7d337db3fbdeb39f488d6405ae6e71f2142cc4514316d0042c85a77d9503e7dfd4637665eb389473145ad31bd8306 - languageName: node - linkType: hard - "eslint@npm:~8.45.0": version: 8.45.0 resolution: "eslint@npm:8.45.0" @@ -19653,17 +19443,6 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" - dependencies: - acorn: "npm:^8.15.0" - acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/9b355b32dbd1cc9f57121d5ee3be258fab87ebeb7c83fc6c02e5af1a74fc8c5ba79fe8c663e69ea112c3e84a1b95e6a2067ac4443ee7813bb85ac7581acb8bf9 - languageName: node - linkType: hard - "espree@npm:^9.6.0": version: 9.6.0 resolution: "espree@npm:9.6.0" @@ -19694,15 +19473,6 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.5.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10/c587fb8ec9ed83f2b1bc97cf2f6854cc30bf784a79d62ba08c6e358bf22280d69aee12827521cf38e69ae9761d23fb7fde593ce315610f85655c139d99b05e5a - languageName: node - linkType: hard - "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -20328,15 +20098,6 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^8.0.0": - version: 8.0.0 - resolution: "file-entry-cache@npm:8.0.0" - dependencies: - flat-cache: "npm:^4.0.0" - checksum: 10/afe55c4de4e0d226a23c1eae62a7219aafb390859122608a89fa4df6addf55c7fd3f1a2da6f5b41e7cdff496e4cf28bbd215d53eab5c817afa96d2b40c81bfb0 - languageName: node - linkType: hard - "file-entry-cache@npm:^9.1.0": version: 9.1.0 resolution: "file-entry-cache@npm:9.1.0" @@ -20676,16 +20437,6 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^4.0.0": - version: 4.0.1 - resolution: "flat-cache@npm:4.0.1" - dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.4" - checksum: 10/58ce851d9045fffc7871ce2bd718bc485ad7e777bf748c054904b87c351ff1080c2c11da00788d78738bfb51b71e4d5ea12d13b98eb36e3358851ffe495b62dc - languageName: node - linkType: hard - "flat-cache@npm:^5.0.0": version: 5.0.0 resolution: "flat-cache@npm:5.0.0" @@ -20712,13 +20463,6 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.2.9": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 10/8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe - languageName: node - linkType: hard - "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -21458,13 +21202,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 10/03939c8af95c6df5014b137cac83aa909090c3a3985caef06ee9a5a669790877af8698ab38007e4c0186873adc14c0b13764acc754b16a754c216cc56aa5f021 - languageName: node - linkType: hard - "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -27138,7 +26875,7 @@ __metadata: languageName: node linkType: hard -"moment@npm:^2.10.2, moment@npm:^2.29.1, moment@npm:^2.29.4, moment@npm:^2.30.1": +"moment@npm:^2.29.1, moment@npm:^2.29.4, moment@npm:^2.30.1": version: 2.30.1 resolution: "moment@npm:2.30.1" checksum: 10/ae42d876d4ec831ef66110bdc302c0657c664991e45cf2afffc4b0f6cd6d251dde11375c982a5c0564ccc0fa593fc564576ddceb8c8845e87c15f58aa6baca69 @@ -31922,14 +31659,9 @@ __metadata: resolution: "rocket.chat@workspace:." dependencies: "@changesets/cli": "npm:^2.27.11" - "@types/chart.js": "npm:^2.9.41" - "@types/js-yaml": "npm:^4.0.9" - "@types/node": "npm:~22.14.1" "@types/stream-buffers": "npm:^3.0.7" node-gyp: "npm:^10.2.0" - ts-node: "npm:^10.9.2" turbo: "npm:~2.5.5" - typescript: "npm:~5.8.3" languageName: unknown linkType: soft @@ -31954,7 +31686,7 @@ __metadata: "@types/ejson": "npm:^2.2.2" "@types/express": "npm:^4.17.23" "@types/fibers": "npm:^3.1.4" - "@types/node": "npm:~22.16.1" + "@types/node": "npm:~22.16.5" "@types/ws": "npm:^8.5.13" ajv: "npm:^8.17.1" bcrypt: "npm:^5.1.1" @@ -31976,7 +31708,7 @@ __metadata: sodium-native: "npm:^4.3.3" sodium-plus: "npm:^0.9.0" ts-node: "npm:^10.9.2" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" uuid: "npm:^11.0.3" ws: "npm:^8.18.2" languageName: unknown @@ -35291,16 +35023,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:~5.8.3": - version: 5.8.3 - resolution: "typescript@npm:5.8.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/65c40944c51b513b0172c6710ee62e951b70af6f75d5a5da745cb7fab132c09ae27ffdf7838996e3ed603bb015dadd099006658046941bd0ba30340cc563ae92 - languageName: node - linkType: hard - "typescript@npm:~5.9.2": version: 5.9.2 resolution: "typescript@npm:5.9.2" @@ -35311,16 +35033,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A~5.8.3#optional!builtin": - version: 5.8.3 - resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/b9b1e73dabac5dc730c041325dbd9c99467c1b0d239f1b74ec3b90d831384af3e2ba973946232df670519147eb51a2c20f6f96163cea2b359f03de1e2091cc4f - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A~5.9.2#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" @@ -35439,13 +35151,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.10.0": - version: 7.10.0 - resolution: "undici-types@npm:7.10.0" - checksum: 10/1f3fe777937690ab8a7a7bccabc8fdf4b3171f4899b5a384fb5f3d6b56c4b5fec2a51fbf345c9dd002ff6716fd440a37fa8fdb0e13af8eca8889f25445875ba3 - languageName: node - linkType: hard - "undici@npm:^5.25.4, undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" From 99bb6ad7a5f06891671253bddcea4f5f48504b62 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 13 Aug 2025 18:19:49 -0300 Subject: [PATCH 004/251] feat: KeyService --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 20 +++++------ packages/e2ee/src/index.ts | 37 ++++++++++++++++---- packages/e2ee/src/result.ts | 11 ++++++ packages/e2ee/tsconfig.json | 8 ++--- 4 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 packages/e2ee/src/result.ts diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 238ce7c5b5d0a..68bd4a655194d 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -82,6 +82,9 @@ class E2E extends Emitter { remove: (keyName) => Promise.resolve(Accounts.storageLocation.removeItem(keyName)), }, crypto, + { + fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), + }, ); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { @@ -213,7 +216,7 @@ class E2E extends Emitter { } async shouldAskForE2EEPassword() { - const { private_key } = await this.getKeysFromLocalStorage(); + const { private_key } = await this.e2ee.getKeysFromLocalStorage(); return this.db_private_key && !private_key; } @@ -305,7 +308,7 @@ class E2E extends Emitter { } private async persistKeys( - { public_key, private_key }: KeyPair, + { public_key, private_key }: Partial, password: string, { force }: { force: boolean } = { force: false }, ): Promise { @@ -338,10 +341,6 @@ class E2E extends Emitter { }); } - getKeysFromLocalStorage(): Promise { - return this.e2ee.getKeysFromLocalStorage(); - } - initiateHandshake() { Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake()); } @@ -376,7 +375,7 @@ class E2E extends Emitter { this.started = true; - let { public_key, private_key } = await this.getKeysFromLocalStorage(); + let { public_key, private_key } = await this.e2ee.getKeysFromLocalStorage(); await this.loadKeysFromDB(); @@ -416,7 +415,7 @@ class E2E extends Emitter { if (!this.db_public_key || !this.db_private_key) { this.setState(E2EEState.LOADING_KEYS); - await this.persistKeys(await this.getKeysFromLocalStorage(), await this.createRandomPassword()); + await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), await this.createRandomPassword()); } const randomPassword = await this.e2ee.getRandomPassword(); @@ -449,7 +448,7 @@ class E2E extends Emitter { } async changePassword(newPassword: string): Promise { - await this.persistKeys(await this.getKeysFromLocalStorage(), newPassword, { force: true }); + await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), newPassword, { force: true }); if (await this.e2ee.getRandomPassword()) { await this.e2ee.storeRandomPassword(newPassword); @@ -459,8 +458,7 @@ class E2E extends Emitter { async loadKeysFromDB(): Promise { try { this.setState(E2EEState.LOADING_KEYS); - const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys'); - + const { public_key, private_key } = await this.e2ee.getKeysFromService(); this.db_public_key = public_key; this.db_private_key = private_key; } catch (error) { diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 29fee26aadbf5..40bb83c27a72b 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -4,28 +4,53 @@ export interface KeyStorage { remove(keyName: string): Promise; } +export interface KeyService { + fetchMyKeys(): Promise; +} + export interface RandomGenerator { getRandomValues(array: T): T; } +export type PrivateKey = string; +export type PublicKey = string; + export type KeyPair = { - public_key: string | null; - private_key: string | null; + public_key: PublicKey; + private_key: PrivateKey; }; export class E2EE { + #service: KeyService; #storage: KeyStorage; #random: RandomGenerator; - constructor(storage: KeyStorage, random: RandomGenerator) { + constructor(storage: KeyStorage, random: RandomGenerator, service: KeyService) { this.#storage = storage; this.#random = random; + this.#service = service; } - async getKeysFromLocalStorage(): Promise { + async getKeysFromLocalStorage(): Promise> { + const public_key = (await this.#storage.load('public_key')) ?? undefined; + const private_key = (await this.#storage.load('private_key')) ?? undefined; + + return { + public_key, + private_key, + }; + } + + async getKeysFromService(): Promise { + const keys = await this.#service.fetchMyKeys(); + + if (!keys) { + throw new Error('Failed to retrieve keys from service'); + } + return { - public_key: await this.#storage.load('public_key'), - private_key: await this.#storage.load('private_key'), + public_key: keys.public_key, + private_key: keys.private_key, }; } diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts new file mode 100644 index 0000000000000..92077b210555e --- /dev/null +++ b/packages/e2ee/src/result.ts @@ -0,0 +1,11 @@ +export type Ok = { + success: true; + data: T; +}; + +export type Err = { + success: false; + error: E; +}; + +export type Result = Ok | Err; diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json index 821712edf70cf..953a0aa0125d1 100644 --- a/packages/e2ee/tsconfig.json +++ b/packages/e2ee/tsconfig.json @@ -9,7 +9,7 @@ "module": "node20", "moduleDetection": "force", "target": "ES2024", - "lib": ["ES2024.Promise", "ES2022.Error", "ES2023.Array", "ES2015"], + "lib": ["ES2024.Promise", "ES2022.Error", "ES2023.Array", "ES2015.Core", "ES2024", "DOM"], "skipLibCheck": true, "noEmit": true, "erasableSyntaxOnly": true, @@ -21,11 +21,11 @@ "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitThis": true, - "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitReturns": true, "libReplacement": false, "isolatedDeclarations": true, - "types": [] + "types": [] }, "include": ["src"], "exclude": [] From 4833a7f271ae74f5234e112c3bfdc63729b203d5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 14 Aug 2025 12:33:31 -0300 Subject: [PATCH 005/251] chore: remove some files and update test --- packages/e2ee/src/__tests__/e2ee.test.ts | 38 ++++++++++++++++------ packages/e2ee/src/storage/localStorage.ts | 0 packages/e2ee/src/storage/memoryStorage.ts | 21 ------------ 3 files changed, 28 insertions(+), 31 deletions(-) delete mode 100644 packages/e2ee/src/storage/localStorage.ts delete mode 100644 packages/e2ee/src/storage/memoryStorage.ts diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index ed4773cfd3835..e30c0220bf422 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -2,9 +2,25 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { E2EE } from '../index.ts'; -import { MemoryStorage } from '../storage/memoryStorage.ts'; +import { E2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; import { webcrypto } from 'node:crypto'; + +class MemoryStorage implements KeyStorage { + private map = new Map(); + + load(keyName: string): Promise { + return Promise.resolve(this.map.get(keyName) ?? null); + } + store(keyName: string, value: string): Promise { + this.map.set(keyName, value); + return Promise.resolve(); + } + remove(keyName: string): Promise { + this.map.delete(keyName); + return Promise.resolve(); + } +} + class DeterministicCrypto implements webcrypto.Crypto { private seq: number[]; private idx = 0; @@ -38,13 +54,14 @@ class DeterministicCrypto implements webcrypto.Crypto { } } -const fromLocalStorage = (storage: Pick<(typeof globalThis)['localStorage'], 'getItem' | 'setItem' | 'removeItem'>) => { - return { - load: (keyName: string) => Promise.resolve(storage.getItem(keyName)), - store: (keyName: string, value: string) => Promise.resolve(storage.setItem(keyName, value)), - remove: (keyName: string) => Promise.resolve(storage.removeItem(keyName)), - }; -}; +class MockedKeyService implements KeyService { + fetchMyKeys(): Promise { + return Promise.resolve({ + public_key: 'mocked_public_key', + private_key: 'mocked_private_key', + }); + } +} test('E2EE createRandomPassword deterministic generation with 5 words', async () => { const storage = new MemoryStorage(); @@ -52,7 +69,8 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. const deterministicCrypto = new DeterministicCrypto([1, 2, 3, 4, 5], webcrypto.subtle, webcrypto.CryptoKey); - const e2ee = new E2EE(fromLocalStorage(storage), deterministicCrypto); + const mockedKeyService = new MockedKeyService(); + const e2ee = new E2EE(storage, deterministicCrypto, mockedKeyService); const pwd = await e2ee.createRandomPassword(); assert.equal(pwd.split(' ').length, 5); assert.equal(await e2ee.getRandomPassword(), pwd); diff --git a/packages/e2ee/src/storage/localStorage.ts b/packages/e2ee/src/storage/localStorage.ts deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/e2ee/src/storage/memoryStorage.ts b/packages/e2ee/src/storage/memoryStorage.ts deleted file mode 100644 index aa59eaf379826..0000000000000 --- a/packages/e2ee/src/storage/memoryStorage.ts +++ /dev/null @@ -1,21 +0,0 @@ -export class MemoryStorage { - #data = new Map(); - clear(): void { - this.#data.clear(); - } - getItem(key: string): string | null { - return this.#data.has(key) ? this.#data.get(key)! : null; - } - key(index: number): string | null { - return Array.from(this.#data.keys())[index] ?? null; - } - removeItem(key: string): void { - this.#data.delete(key); - } - setItem(key: string, value: string): void { - this.#data.set(key, value); - } - get length(): number { - return this.#data.size; - } -} From 146ee834bf8c75785bc6fd1cb4ee6309212f428b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 14 Aug 2025 18:10:08 -0300 Subject: [PATCH 006/251] feat: add e2ee-node --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 9 +- packages/e2ee-node/.gitignore | 1 + packages/e2ee-node/README.md | 98 ++++++++++++ packages/e2ee-node/package.json | 36 +++++ packages/e2ee-node/scripts/postbuild.ts | 6 + packages/e2ee-node/src/__tests__/e2ee.test.ts | 24 +++ .../e2ee-node/src/__tests__/keycodec.test.ts | 62 ++++++++ packages/e2ee-node/src/index.ts | 37 +++++ packages/e2ee-node/tsconfig.build.json | 8 + packages/e2ee-node/tsconfig.json | 47 ++++++ packages/e2ee/package.json | 1 - packages/e2ee/src/__tests__/e2ee.test.ts | 142 +++++++++--------- packages/e2ee/src/__tests__/keycodec.test.ts | 60 ++++++++ packages/e2ee/src/index.ts | 27 ++-- packages/e2ee/src/keyCodec.ts | 108 +++++++++++++ packages/e2ee/src/result.ts | 11 -- packages/e2ee/tsconfig.json | 47 ++++-- yarn.lock | 24 ++- 18 files changed, 626 insertions(+), 122 deletions(-) create mode 100644 packages/e2ee-node/.gitignore create mode 100644 packages/e2ee-node/README.md create mode 100644 packages/e2ee-node/package.json create mode 100644 packages/e2ee-node/scripts/postbuild.ts create mode 100644 packages/e2ee-node/src/__tests__/e2ee.test.ts create mode 100644 packages/e2ee-node/src/__tests__/keycodec.test.ts create mode 100644 packages/e2ee-node/src/index.ts create mode 100644 packages/e2ee-node/tsconfig.build.json create mode 100644 packages/e2ee-node/tsconfig.json create mode 100644 packages/e2ee/src/__tests__/keycodec.test.ts create mode 100644 packages/e2ee/src/keyCodec.ts delete mode 100644 packages/e2ee/src/result.ts diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 68bd4a655194d..0737611ae759d 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { E2EE, type KeyPair } from '@rocket.chat/e2ee'; +import { E2EEBase, type KeyPair } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -68,20 +68,20 @@ class E2E extends Emitter { private state: E2EEState; - private e2ee: E2EE; + private e2ee: E2EEBase; constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = new E2EE( + this.e2ee = new E2EEBase( + crypto, { load: (keyName) => Promise.resolve(Accounts.storageLocation.getItem(keyName)), store: (keyName, value) => Promise.resolve(Accounts.storageLocation.setItem(keyName, value)), remove: (keyName) => Promise.resolve(Accounts.storageLocation.removeItem(keyName)), }, - crypto, { fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), }, @@ -459,6 +459,7 @@ class E2E extends Emitter { try { this.setState(E2EEState.LOADING_KEYS); const { public_key, private_key } = await this.e2ee.getKeysFromService(); + this.db_public_key = public_key; this.db_private_key = private_key; } catch (error) { diff --git a/packages/e2ee-node/.gitignore b/packages/e2ee-node/.gitignore new file mode 100644 index 0000000000000..8658e1ccd0ef6 --- /dev/null +++ b/packages/e2ee-node/.gitignore @@ -0,0 +1 @@ +tsconfig.build.tsbuildinfo \ No newline at end of file diff --git a/packages/e2ee-node/README.md b/packages/e2ee-node/README.md new file mode 100644 index 0000000000000..46e1e13e99b36 --- /dev/null +++ b/packages/e2ee-node/README.md @@ -0,0 +1,98 @@ +# @rocket.chat/e2e-crypto-core + +Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: + +- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) +- Double Ratchet skeleton for forward secrecy +- Group key derivation & rotation helpers +- Message serialization & integrity (MAC) helpers +- Replay protection utilities +- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage + +> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. + +## Design Principles + +1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. +2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. +3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). +4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. +5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. + +## Core Modules + +| Module | Purpose | +| ------ | ------- | +| `providers/interfaces.ts` | Abstract provider contracts | +| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | +| `core/keyManagement.ts` | Identity & group key helpers | +| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | +| `core/messageIntegrity.ts` | MAC computation / verification | +| `core/replayProtection.ts` | Replay cache + ID helpers | +| `protocol/session.ts` | High-level session manager wrapper | + +## Provider Interfaces + +Implement and inject: + +```ts +import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; + +const ctx: ProviderContext = { + crypto: /* your CryptoProvider */, + rng: /* RandomnessProvider */, + hash: /* HashProvider */, + storage: /* KeyStorageProvider */, + net: /* optional NetworkProvider */, +}; +``` + +## Basic Usage (1:1 Session) + +```ts +import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; + +// ctx: ProviderContext injected +const keyMgr = new KeyManager(ctx); +await keyMgr.ensureIdentityKey(); + +// Assume we fetched peer pre-key bundle externally +const sessionMgr = new SessionManager(ctx); +const init = { + theirIdentityKey: peerBundle.identityKey, + theirSignedPreKey: peerBundle.signedPreKey, + theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], + isInitiator: true, +}; + +// Encrypt +const plaintext = new TextEncoder().encode('hello'); +const payload = await sessionMgr.encrypt('peer-user-id', plaintext); +const wire = serializePayload(payload); // send over DDP / WebSocket / REST + +// Decrypt (other side) +const received = deserializePayload(wire); +const result = await sessionMgr.decrypt('peer-user-id', received); +console.log(new TextDecoder().decode(result.plaintext)); +``` + +## Group Messaging (Conceptual) + +Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. + +## Error Handling + +All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. + +## Extending / Hardening + +Production needs (out of scope of this skeleton): +- Skipped message key handling & storage +- Full DH ratchet step logic & header validation +- Signature verification of pre-key bundles +- Authentic secondary MAC with independent key material +- More robust replay filtering (e.g. bloom filter) +- Formal test vectors & interoperability tests + +## License +MIT diff --git a/packages/e2ee-node/package.json b/packages/e2ee-node/package.json new file mode 100644 index 0000000000000..23a1e31f299ab --- /dev/null +++ b/packages/e2ee-node/package.json @@ -0,0 +1,36 @@ +{ + "name": "@rocket.chat/e2ee-node", + "private": true, + "version": "0.1.0", + "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "sideEffects": false, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", + "typecheck": "tsc -p tsconfig.json", + "lint": "eslint 'src/**/*.ts'", + "testunit": "node --experimental-strip-types --test" + }, + "dependencies": { + "@rocket.chat/e2ee": "workspace:^" + }, + "devDependencies": { + "@types/node": "~22.16.5", + "typescript": "~5.9.2" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/e2ee-node/scripts/postbuild.ts b/packages/e2ee-node/scripts/postbuild.ts new file mode 100644 index 0000000000000..be23ae772367e --- /dev/null +++ b/packages/e2ee-node/scripts/postbuild.ts @@ -0,0 +1,6 @@ +import { writeFile } from 'node:fs/promises'; + +// Create the CJS module entry point +// Require the E2EE class from the ESM module + +await writeFile('./dist/index.cjs', "module.exports = require('./index.js');\n"); diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts new file mode 100644 index 0000000000000..b7ce28697f0e3 --- /dev/null +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { E2EE } from '../index.ts'; +import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; + +class MockedKeyService implements KeyService { + fetchMyKeys(): Promise { + return Promise.resolve({ + public_key: 'mocked_public_key', + private_key: 'mocked_private_key', + }); + } +} + +test('E2EE createRandomPassword deterministic generation with 5 words', async () => { + // Inject custom word list by temporarily defining dynamic import. + // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. + // So we skip testing exact phrase (depends on wordList) and just assert shape. + const mockedKeyService = new MockedKeyService(); + const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const pwd = await e2ee.createRandomPassword(); + assert.equal(pwd.split(' ').length, 5); + assert.equal(await e2ee.getRandomPassword(), pwd); +}); diff --git a/packages/e2ee-node/src/__tests__/keycodec.test.ts b/packages/e2ee-node/src/__tests__/keycodec.test.ts new file mode 100644 index 0000000000000..b20c83a8b397c --- /dev/null +++ b/packages/e2ee-node/src/__tests__/keycodec.test.ts @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { webcrypto } from 'node:crypto'; +import { KeyCodec } from '@rocket.chat/e2ee'; + +const createKeyCodec = () => + new KeyCodec({ + exportJsonWebKey: (key) => webcrypto.subtle.exportKey('jwk', key), + getRandomValues: (array) => crypto.getRandomValues(array), + importRawKey: (raw) => webcrypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + generateKeyPair: () => + webcrypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ), + decodeBase64: (input) => new Uint8Array(Buffer.from(input, 'base64')), + encodeBase64: (input) => Buffer.from(input).toString('base64'), + decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + encryptAesCbc: (key, iv, data) => webcrypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + }); + +test('KeyCodec roundtrip (v1 structured encoding)', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const privStr = JSON.stringify(privateJWK); + const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); + const enc = await codec.encodePrivateKey(privStr, masterKey); + const dec = await codec.decodePrivateKey(enc, masterKey); + assert.equal(dec, privStr); +}); + +test('KeyCodec wrong password fails', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const privStr = JSON.stringify(privateJWK); + const salt1 = new TextEncoder().encode('salt1'); + const masterKey = await codec.deriveMasterKey('correct', salt1); + const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); + const enc = await codec.encodePrivateKey(privStr, masterKey); + await assert.rejects(() => codec.decodePrivateKey(enc, masterKey2)); +}); + +test('KeyCodec tamper detection (ciphertext)', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const privStr = JSON.stringify(privateJWK); + const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); + const enc = await codec.encodePrivateKey(privStr, masterKey); + // Tamper with ctB64 + const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; + await assert.rejects(() => codec.decodePrivateKey(mutated, masterKey)); +}); + +test('KeyCodec legacy roundtrip', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const privStr = JSON.stringify(privateJWK); + const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); + const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); + assert.equal(dec, privStr); +}); diff --git a/packages/e2ee-node/src/index.ts b/packages/e2ee-node/src/index.ts new file mode 100644 index 0000000000000..b1f76ad850dcc --- /dev/null +++ b/packages/e2ee-node/src/index.ts @@ -0,0 +1,37 @@ +import { E2EEBase, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; +import { webcrypto } from 'node:crypto'; + +class MemoryStorage implements KeyStorage { + private map = new Map(); + + load(keyName: string): Promise { + return Promise.resolve(this.map.get(keyName) ?? null); + } + store(keyName: string, value: string): Promise { + this.map.set(keyName, value); + return Promise.resolve(); + } + remove(keyName: string): Promise { + this.map.delete(keyName); + return Promise.resolve(); + } +} + +export class E2EE extends E2EEBase { + constructor(keyStorage: KeyStorage, keyService: KeyService) { + super( + { + getRandomValues(array) { + return webcrypto.getRandomValues(array); + }, + }, + keyStorage, + keyService, + ); + } + + static withMemoryStorage(keyService: KeyService): E2EE { + const memoryStorage = new MemoryStorage(); + return new E2EE(memoryStorage, keyService); + } +} diff --git a/packages/e2ee-node/tsconfig.build.json b/packages/e2ee-node/tsconfig.build.json new file mode 100644 index 0000000000000..cf4278ae09d96 --- /dev/null +++ b/packages/e2ee-node/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/e2ee-node/tsconfig.json b/packages/e2ee-node/tsconfig.json new file mode 100644 index 0000000000000..6e35b8ea9528b --- /dev/null +++ b/packages/e2ee-node/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "composite": false, + "module": "node20", + + "target": "ES2024", + "types": ["node"], + + /** Emit */ + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + + /** Language and Environment */ + "libReplacement": false, + + /** Linting */ + "strict": true, + "erasableSyntaxOnly": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "isolatedModules": true, + "isolatedDeclarations": true, + + "allowJs": false, + "esModuleInterop": false, + "skipLibCheck": false + }, + "include": ["src"], + "exclude": [] +} diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 3dfb9f05f069c..3b3a853072e58 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -23,7 +23,6 @@ "testunit": "node --experimental-strip-types --test" }, "devDependencies": { - "@types/node": "~22.16.5", "typescript": "~5.9.2" }, "license": "MIT", diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index e30c0220bf422..12f026abfe02c 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,77 +1,75 @@ -/// +// import { test } from 'node:test'; +// import * as assert from 'node:assert/strict'; +// import { E2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; +// import { webcrypto } from 'node:crypto'; -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { E2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; -import { webcrypto } from 'node:crypto'; +// class MemoryStorage implements KeyStorage { +// private map = new Map(); -class MemoryStorage implements KeyStorage { - private map = new Map(); +// load(keyName: string): Promise { +// return Promise.resolve(this.map.get(keyName) ?? null); +// } +// store(keyName: string, value: string): Promise { +// this.map.set(keyName, value); +// return Promise.resolve(); +// } +// remove(keyName: string): Promise { +// this.map.delete(keyName); +// return Promise.resolve(); +// } +// } - load(keyName: string): Promise { - return Promise.resolve(this.map.get(keyName) ?? null); - } - store(keyName: string, value: string): Promise { - this.map.set(keyName, value); - return Promise.resolve(); - } - remove(keyName: string): Promise { - this.map.delete(keyName); - return Promise.resolve(); - } -} +// class DeterministicCrypto implements webcrypto.Crypto { +// private seq: number[]; +// private idx = 0; +// constructor(seq: number[], subtle: webcrypto.SubtleCrypto, CryptoKey: webcrypto.CryptoKeyConstructor) { +// this.seq = seq; +// // Use provided SubtleCrypto, else fall back to environment's, else a no-op placeholder. +// this.subtle = subtle; +// this.CryptoKey = CryptoKey; +// } +// CryptoKey: webcrypto.CryptoKeyConstructor; +// subtle: webcrypto.SubtleCrypto; +// getRandomValues(array: T): T { +// if (array instanceof Uint32Array) { +// for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; +// } else if (array instanceof Uint8Array) { +// for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; +// } +// return array; +// } +// get [Symbol.toStringTag]() { +// return 'DeterministicCrypto'; +// } +// randomUUID(): `${string}-${string}-${string}-${string}-${string}` { +// const bytes = new Uint8Array(16); +// this.getRandomValues(bytes); +// // RFC 4122 variant & version adjustments +// bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 +// bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 +// const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); +// return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}` as `${string}-${string}-${string}-${string}-${string}`; +// } +// } -class DeterministicCrypto implements webcrypto.Crypto { - private seq: number[]; - private idx = 0; - constructor(seq: number[], subtle: webcrypto.SubtleCrypto, CryptoKey: webcrypto.CryptoKeyConstructor) { - this.seq = seq; - // Use provided SubtleCrypto, else fall back to environment's, else a no-op placeholder. - this.subtle = subtle; - this.CryptoKey = CryptoKey; - } - CryptoKey: webcrypto.CryptoKeyConstructor; - subtle: webcrypto.SubtleCrypto; - getRandomValues(array: T): T { - if (array instanceof Uint32Array) { - for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; - } else if (array instanceof Uint8Array) { - for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; - } - return array; - } - get [Symbol.toStringTag]() { - return 'DeterministicCrypto'; - } - randomUUID(): `${string}-${string}-${string}-${string}-${string}` { - const bytes = new Uint8Array(16); - this.getRandomValues(bytes); - // RFC 4122 variant & version adjustments - bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); - return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}` as `${string}-${string}-${string}-${string}-${string}`; - } -} +// class MockedKeyService implements KeyService { +// fetchMyKeys(): Promise { +// return Promise.resolve({ +// public_key: 'mocked_public_key', +// private_key: 'mocked_private_key', +// }); +// } +// } -class MockedKeyService implements KeyService { - fetchMyKeys(): Promise { - return Promise.resolve({ - public_key: 'mocked_public_key', - private_key: 'mocked_private_key', - }); - } -} - -test('E2EE createRandomPassword deterministic generation with 5 words', async () => { - const storage = new MemoryStorage(); - // Inject custom word list by temporarily defining dynamic import. - // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. - // So we skip testing exact phrase (depends on wordList) and just assert shape. - const deterministicCrypto = new DeterministicCrypto([1, 2, 3, 4, 5], webcrypto.subtle, webcrypto.CryptoKey); - const mockedKeyService = new MockedKeyService(); - const e2ee = new E2EE(storage, deterministicCrypto, mockedKeyService); - const pwd = await e2ee.createRandomPassword(); - assert.equal(pwd.split(' ').length, 5); - assert.equal(await e2ee.getRandomPassword(), pwd); -}); +// test('E2EE createRandomPassword deterministic generation with 5 words', async () => { +// const storage = new MemoryStorage(); +// // Inject custom word list by temporarily defining dynamic import. +// // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. +// // So we skip testing exact phrase (depends on wordList) and just assert shape. +// const deterministicCrypto = new DeterministicCrypto([1, 2, 3, 4, 5], webcrypto.subtle, webcrypto.CryptoKey); +// const mockedKeyService = new MockedKeyService(); +// const e2ee = new E2EE(storage, deterministicCrypto, mockedKeyService); +// const pwd = await e2ee.createRandomPassword(); +// assert.equal(pwd.split(' ').length, 5); +// assert.equal(await e2ee.getRandomPassword(), pwd); +// }); diff --git a/packages/e2ee/src/__tests__/keycodec.test.ts b/packages/e2ee/src/__tests__/keycodec.test.ts new file mode 100644 index 0000000000000..ef6d1b9b10251 --- /dev/null +++ b/packages/e2ee/src/__tests__/keycodec.test.ts @@ -0,0 +1,60 @@ +// import { test } from 'node:test'; +// import * as assert from 'node:assert/strict'; +// import { webcrypto } from 'node:crypto'; +// import { KeyCodec, type CryptoProvider } from '../keyCodec.ts'; + +// const deps: CryptoProvider = { +// exportJsonWebKey: (key: CryptoKey): Promise => { +// return webcrypto.subtle.exportKey('jwk', key); +// }, +// getRandomValues: (array) => crypto.getRandomValues(array), +// importRawKey: (raw) => webcrypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), +// generateKeyPair: () => +// webcrypto.subtle.generateKey( +// { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, +// true, +// ['encrypt', 'decrypt'], +// ), +// decodeBase64: (input: string) => webcrypto.subtle.decodeBase64(input), +// }; + +// test('KeyCodec roundtrip (v1 structured encoding)', async () => { +// const codec = new KeyCodec(deps); +// const { privateJWK } = await codec.generateRSAKeyPair(); +// const privStr = JSON.stringify(privateJWK); +// const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); +// const enc = await codec.encodePrivateKey(privStr, masterKey); +// const dec = await codec.decodePrivateKey(enc, masterKey); +// assert.equal(dec, privStr); +// }); + +// test('KeyCodec wrong password fails', async () => { +// const codec = new KeyCodec(deps); +// const { privateJWK } = await codec.generateRSAKeyPair(); +// const privStr = JSON.stringify(privateJWK); +// const salt1 = new TextEncoder().encode('salt1'); +// const masterKey = await codec.deriveMasterKey('correct', salt1); +// const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); +// const enc = await codec.encodePrivateKey(privStr, masterKey); +// await assert.rejects(() => codec.decodePrivateKey(enc, masterKey2)); +// }); + +// test('KeyCodec tamper detection (ciphertext)', async () => { +// const codec = new KeyCodec(deps); +// const { privateJWK } = await codec.generateRSAKeyPair(); +// const privStr = JSON.stringify(privateJWK); +// const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); +// const enc = await codec.encodePrivateKey(privStr, masterKey); +// // Tamper with ctB64 +// const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; +// await assert.rejects(() => codec.decodePrivateKey(mutated, masterKey)); +// }); + +// test('KeyCodec legacy roundtrip', async () => { +// const codec = new KeyCodec(deps); +// const { privateJWK } = await codec.generateRSAKeyPair(); +// const privStr = JSON.stringify(privateJWK); +// const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); +// const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); +// assert.equal(dec, privStr); +// }); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 40bb83c27a72b..e12e7485c3ff7 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,3 +1,5 @@ +export { KeyCodec } from './keyCodec.ts'; + export interface KeyStorage { load(keyName: string): Promise; store(keyName: string, value: string): Promise; @@ -8,36 +10,34 @@ export interface KeyService { fetchMyKeys(): Promise; } -export interface RandomGenerator { - getRandomValues(array: T): T; +export interface CryptoProvider { + getRandomValues(array: Uint32Array): Uint32Array; } export type PrivateKey = string; export type PublicKey = string; - export type KeyPair = { public_key: PublicKey; private_key: PrivateKey; }; -export class E2EE { +export abstract class E2EEBase { #service: KeyService; #storage: KeyStorage; - #random: RandomGenerator; + #crypto: CryptoProvider; - constructor(storage: KeyStorage, random: RandomGenerator, service: KeyService) { + constructor(crypto: CryptoProvider, storage: KeyStorage, service: KeyService) { this.#storage = storage; - this.#random = random; this.#service = service; + this.#crypto = crypto; } async getKeysFromLocalStorage(): Promise> { const public_key = (await this.#storage.load('public_key')) ?? undefined; const private_key = (await this.#storage.load('private_key')) ?? undefined; - return { - public_key, - private_key, + ...(public_key ? { public_key } : {}), + ...(private_key ? { private_key } : {}), }; } @@ -48,17 +48,14 @@ export class E2EE { throw new Error('Failed to retrieve keys from service'); } - return { - public_key: keys.public_key, - private_key: keys.private_key, - }; + return keys; } async #generateMnemonicPhrase(n: number, sep = ' '): Promise { const wordList = await import('./wordList.ts'); const result = new Array(n); const randomBuffer = new Uint32Array(n); - this.#random.getRandomValues(randomBuffer); + this.#crypto.getRandomValues(randomBuffer); for (let i = 0; i < n; i++) { result[i] = wordList.v1[randomBuffer[i]! % wordList.v1.length]!; } diff --git a/packages/e2ee/src/keyCodec.ts b/packages/e2ee/src/keyCodec.ts new file mode 100644 index 0000000000000..354dbf1b8217c --- /dev/null +++ b/packages/e2ee/src/keyCodec.ts @@ -0,0 +1,108 @@ +export interface CryptoProvider { + exportJsonWebKey(key: CryptoKey): Promise; + importRawKey(raw: ArrayBuffer | Uint8Array): Promise; + getRandomValues(array: Uint8Array): Uint8Array; + generateKeyPair(): Promise; + decodeBase64(input: string): Uint8Array; + encodeBase64(input: ArrayBuffer): string; + decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; + encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; +} + +export interface EncodedPrivateKeyV1 { + v: 1; + mode: 'AES-CBC'; + kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number }; + ivB64: string; + /** ciphertext */ + ctB64: string; +} + +export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union + +const ITERATIONS = 1000; // mirrors legacy implementation + +export class KeyCodec { + #crypto: CryptoProvider; + + constructor(deps: CryptoProvider) { + this.#crypto = deps; + } + + async generateRSAKeyPair(): Promise<{ publicJWK: JsonWebKey; privateJWK: JsonWebKey }> { + const keyPair = await this.#crypto.generateKeyPair(); + const publicJWK = await this.#crypto.exportJsonWebKey(keyPair.publicKey); + const privateJWK = await this.#crypto.exportJsonWebKey(keyPair.privateKey); + return { publicJWK, privateJWK }; + } + + async encodePrivateKey(privateKeyJwkString: string, masterKey: CryptoKey): Promise { + const { getRandomValues, encodeBase64, encryptAesCbc } = this.#crypto; + const iv = getRandomValues(new Uint8Array(16)); + const data = new TextEncoder().encode(privateKeyJwkString); + const ct = await encryptAesCbc(masterKey, iv, data); + return { + v: 1, + mode: 'AES-CBC', + kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations: ITERATIONS }, + ivB64: encodeBase64(iv.buffer), + ctB64: encodeBase64(ct), + }; + } + + async deriveMasterKey(password: string, salt: Uint8Array): Promise { + if (!password) throw new Error('Password is required'); + const enc = new TextEncoder(); + const baseKey = await this.#crypto.importRawKey(enc.encode(password)); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, + baseKey, + { name: 'AES-CBC', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); + } + + async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { + if (encoded.v !== 1) throw new Error(`Unsupported encoded private key version: ${encoded.v}`); + if (encoded.mode !== 'AES-CBC') throw new Error(`Unsupported cipher mode: ${encoded.mode}`); + const iv = this.#crypto.decodeBase64(encoded.ivB64); + const ct = this.#crypto.decodeBase64(encoded.ctB64); + try { + const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); + return new TextDecoder().decode(plain); + } catch (e) { + throw new Error('Failed to decrypt private key'); + } + } + + // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. + async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { + const { getRandomValues } = this.#crypto; + const salt = new TextEncoder().encode(saltStr); + const masterKey = await this.deriveMasterKey(password, salt); + const iv = getRandomValues(new Uint8Array(16)); + const data = new TextEncoder().encode(privateKeyJwkString); + const ct = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, masterKey, data); + const ctBytes = new Uint8Array(ct); + const out = new Uint8Array(iv.length + ctBytes.length); + out.set(iv, 0); + out.set(ctBytes, iv.length); + return out; + } + + async legacyDecrypt(joined: ArrayBuffer | Uint8Array, password: string, saltStr: string): Promise { + const bytes = joined instanceof Uint8Array ? joined : new Uint8Array(joined); + if (bytes.length < 17) throw new Error('Invalid legacy encrypted blob'); + const iv = bytes.slice(0, 16); + const ct = bytes.slice(16); + const salt = new TextEncoder().encode(saltStr); + const masterKey = await this.deriveMasterKey(password, salt); + try { + const plain = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, masterKey, ct); + return new TextDecoder().decode(plain, { stream: false }); + } catch (e) { + throw new Error('Failed to decrypt legacy private key'); + } + } +} diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts deleted file mode 100644 index 92077b210555e..0000000000000 --- a/packages/e2ee/src/result.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type Ok = { - success: true; - data: T; -}; - -export type Err = { - success: false; - error: E; -}; - -export type Result = Ok | Err; diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json index 953a0aa0125d1..5f77ebf8a676f 100644 --- a/packages/e2ee/tsconfig.json +++ b/packages/e2ee/tsconfig.json @@ -1,31 +1,50 @@ { "compilerOptions": { + "composite": false, + "module": "es2022", + "target": "ES2024", + "types": [], + "typeRoots": [], + + /** Modules */ "rootDir": "src", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + + /** Emit */ + "noEmit": true, "outDir": "dist", "declaration": true, "declarationMap": true, - "composite": false, - "strict": true, - "module": "node20", + "verbatimModuleSyntax": true, "moduleDetection": "force", - "target": "ES2024", - "lib": ["ES2024.Promise", "ES2022.Error", "ES2023.Array", "ES2015.Core", "ES2024", "DOM"], - "skipLibCheck": true, - "noEmit": true, + + /** Language and Environment */ + "libReplacement": false, + + /** Linting */ + "strict": true, "erasableSyntaxOnly": true, - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - "noImplicitAny": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, "noUncheckedIndexedAccess": true, "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitThis": true, - "noImplicitReturns": true, - "libReplacement": false, + "isolatedModules": true, "isolatedDeclarations": true, - "types": [] + "allowJs": false, + "esModuleInterop": false, + "skipLibCheck": false + }, + "typeAcquisition": { + "enable": false, + "disableFilenameBasedTypeAcquisition": true }, "include": ["src"], "exclude": [] diff --git a/yarn.lock b/yarn.lock index 7437718af4f14..2178a52ee1800 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7250,11 +7250,20 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/e2ee-node@workspace:packages/e2ee-node": + version: 0.0.0-use.local + resolution: "@rocket.chat/e2ee-node@workspace:packages/e2ee-node" + dependencies: + "@rocket.chat/e2ee": "workspace:^" + "@types/node": "npm:~22.16.5" + typescript: "npm:~5.9.2" + languageName: unknown + linkType: soft + "@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": version: 0.0.0-use.local resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: - "@types/node": "npm:~22.16.5" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -11953,6 +11962,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:~22.16.5": + version: 22.16.5 + resolution: "@types/node@npm:22.16.5" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10/ba45b5c9113cbc5edb12960fcfe7e80db2c998af5c1931264240695b27d756570d92462150b95781bd67a03aa82111cc970ab0f4504eb99213edff8bf425354e + languageName: node + linkType: hard + "@types/nodemailer@npm:*, @types/nodemailer@npm:^6.4.17": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -31650,13 +31668,9 @@ __metadata: resolution: "rocket.chat@workspace:." dependencies: "@changesets/cli": "npm:^2.27.11" - "@types/chart.js": "npm:^2.9.41" - "@types/js-yaml": "npm:^4.0.9" - "@types/node": "npm:~22.16.1" "@types/stream-buffers": "npm:^3.0.7" node-gyp: "npm:^10.2.0" turbo: "npm:~2.5.5" - typescript: "npm:~5.9.2" languageName: unknown linkType: soft From 09418414e07bbc3991aabcf20a5348bfec713848 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 14 Aug 2025 19:34:25 -0300 Subject: [PATCH 007/251] feat: e2ee-web --- packages/e2ee-node/src/__tests__/e2ee.test.ts | 4 +- .../e2ee-node/src/__tests__/keycodec.test.ts | 31 +- packages/e2ee-node/src/index.ts | 20 +- packages/e2ee-node/src/keyCodec.ts | 23 + packages/e2ee-web/README.md | 98 +++ packages/e2ee-web/package.json | 41 + .../KeyCodec-legacy-roundtrip-1.png | Bin 0 -> 5807 bytes ...c-roundtrip--v1-structured-encoding--1.png | Bin 0 -> 5714 bytes packages/e2ee-web/src/__tests__/e2ee.test.ts | 24 + .../e2ee-web/src/__tests__/keycodec.test.ts | 43 + packages/e2ee-web/src/index.ts | 29 + packages/e2ee-web/src/keyCodec.ts | 38 + packages/e2ee-web/tsconfig.build.json | 8 + packages/e2ee-web/tsconfig.json | 47 ++ packages/e2ee-web/vitest.config.ts | 14 + packages/e2ee/src/index.ts | 27 +- packages/e2ee/src/keyCodec.ts | 71 +- yarn.lock | 758 +++++++++++++++++- 18 files changed, 1181 insertions(+), 95 deletions(-) create mode 100644 packages/e2ee-node/src/keyCodec.ts create mode 100644 packages/e2ee-web/README.md create mode 100644 packages/e2ee-web/package.json create mode 100644 packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png create mode 100644 packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png create mode 100644 packages/e2ee-web/src/__tests__/e2ee.test.ts create mode 100644 packages/e2ee-web/src/__tests__/keycodec.test.ts create mode 100644 packages/e2ee-web/src/index.ts create mode 100644 packages/e2ee-web/src/keyCodec.ts create mode 100644 packages/e2ee-web/tsconfig.build.json create mode 100644 packages/e2ee-web/tsconfig.json create mode 100644 packages/e2ee-web/vitest.config.ts diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index b7ce28697f0e3..65bdf8f06e6fc 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -import { E2EE } from '../index.ts'; +import { NodeE2EE } from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; class MockedKeyService implements KeyService { @@ -17,7 +17,7 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. const mockedKeyService = new MockedKeyService(); - const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const e2ee = NodeE2EE.withMemoryStorage(mockedKeyService); const pwd = await e2ee.createRandomPassword(); assert.equal(pwd.split(' ').length, 5); assert.equal(await e2ee.getRandomPassword(), pwd); diff --git a/packages/e2ee-node/src/__tests__/keycodec.test.ts b/packages/e2ee-node/src/__tests__/keycodec.test.ts index b20c83a8b397c..5bfdf94b1b560 100644 --- a/packages/e2ee-node/src/__tests__/keycodec.test.ts +++ b/packages/e2ee-node/src/__tests__/keycodec.test.ts @@ -1,52 +1,33 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -import { webcrypto } from 'node:crypto'; -import { KeyCodec } from '@rocket.chat/e2ee'; +import { NodeKeyCodec } from '../keyCodec.ts'; -const createKeyCodec = () => - new KeyCodec({ - exportJsonWebKey: (key) => webcrypto.subtle.exportKey('jwk', key), - getRandomValues: (array) => crypto.getRandomValues(array), - importRawKey: (raw) => webcrypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), - generateKeyPair: () => - webcrypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt'], - ), - decodeBase64: (input) => new Uint8Array(Buffer.from(input, 'base64')), - encodeBase64: (input) => Buffer.from(input).toString('base64'), - decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), - encryptAesCbc: (key, iv, data) => webcrypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), - }); +const createKeyCodec = () => new NodeKeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const privStr = JSON.stringify(privateJWK); const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); - const enc = await codec.encodePrivateKey(privStr, masterKey); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); - assert.equal(dec, privStr); + assert.equal(dec, privateJWK); }); test('KeyCodec wrong password fails', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const privStr = JSON.stringify(privateJWK); const salt1 = new TextEncoder().encode('salt1'); const masterKey = await codec.deriveMasterKey('correct', salt1); const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); - const enc = await codec.encodePrivateKey(privStr, masterKey); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); await assert.rejects(() => codec.decodePrivateKey(enc, masterKey2)); }); test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const privStr = JSON.stringify(privateJWK); const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); - const enc = await codec.encodePrivateKey(privStr, masterKey); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); // Tamper with ctB64 const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; await assert.rejects(() => codec.decodePrivateKey(mutated, masterKey)); diff --git a/packages/e2ee-node/src/index.ts b/packages/e2ee-node/src/index.ts index b1f76ad850dcc..1ebd7ffa18fe0 100644 --- a/packages/e2ee-node/src/index.ts +++ b/packages/e2ee-node/src/index.ts @@ -1,5 +1,5 @@ -import { E2EEBase, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; -import { webcrypto } from 'node:crypto'; +import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; +import { NodeKeyCodec } from './keyCodec.ts'; class MemoryStorage implements KeyStorage { private map = new Map(); @@ -17,21 +17,13 @@ class MemoryStorage implements KeyStorage { } } -export class E2EE extends E2EEBase { +export class NodeE2EE extends BaseE2EE { constructor(keyStorage: KeyStorage, keyService: KeyService) { - super( - { - getRandomValues(array) { - return webcrypto.getRandomValues(array); - }, - }, - keyStorage, - keyService, - ); + super(new NodeKeyCodec(), keyStorage, keyService); } - static withMemoryStorage(keyService: KeyService): E2EE { + static withMemoryStorage(keyService: KeyService): NodeE2EE { const memoryStorage = new MemoryStorage(); - return new E2EE(memoryStorage, keyService); + return new NodeE2EE(memoryStorage, keyService); } } diff --git a/packages/e2ee-node/src/keyCodec.ts b/packages/e2ee-node/src/keyCodec.ts new file mode 100644 index 0000000000000..8efddca781763 --- /dev/null +++ b/packages/e2ee-node/src/keyCodec.ts @@ -0,0 +1,23 @@ +import { BaseKeyCodec } from '@rocket.chat/e2ee'; + +export class NodeKeyCodec extends BaseKeyCodec { + constructor() { + super({ + exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), + getRandomUint8Array: (array) => crypto.getRandomValues(array), + getRandomUint16Array: (array) => crypto.getRandomValues(array), + getRandomUint32Array: (array) => crypto.getRandomValues(array), + importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + generateKeyPair: () => + crypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ), + decodeBase64: (input) => new Uint8Array(Buffer.from(input, 'base64')), + encodeBase64: (input) => Buffer.from(input).toString('base64'), + decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + }); + } +} diff --git a/packages/e2ee-web/README.md b/packages/e2ee-web/README.md new file mode 100644 index 0000000000000..46e1e13e99b36 --- /dev/null +++ b/packages/e2ee-web/README.md @@ -0,0 +1,98 @@ +# @rocket.chat/e2e-crypto-core + +Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: + +- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) +- Double Ratchet skeleton for forward secrecy +- Group key derivation & rotation helpers +- Message serialization & integrity (MAC) helpers +- Replay protection utilities +- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage + +> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. + +## Design Principles + +1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. +2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. +3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). +4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. +5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. + +## Core Modules + +| Module | Purpose | +| ------ | ------- | +| `providers/interfaces.ts` | Abstract provider contracts | +| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | +| `core/keyManagement.ts` | Identity & group key helpers | +| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | +| `core/messageIntegrity.ts` | MAC computation / verification | +| `core/replayProtection.ts` | Replay cache + ID helpers | +| `protocol/session.ts` | High-level session manager wrapper | + +## Provider Interfaces + +Implement and inject: + +```ts +import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; + +const ctx: ProviderContext = { + crypto: /* your CryptoProvider */, + rng: /* RandomnessProvider */, + hash: /* HashProvider */, + storage: /* KeyStorageProvider */, + net: /* optional NetworkProvider */, +}; +``` + +## Basic Usage (1:1 Session) + +```ts +import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; + +// ctx: ProviderContext injected +const keyMgr = new KeyManager(ctx); +await keyMgr.ensureIdentityKey(); + +// Assume we fetched peer pre-key bundle externally +const sessionMgr = new SessionManager(ctx); +const init = { + theirIdentityKey: peerBundle.identityKey, + theirSignedPreKey: peerBundle.signedPreKey, + theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], + isInitiator: true, +}; + +// Encrypt +const plaintext = new TextEncoder().encode('hello'); +const payload = await sessionMgr.encrypt('peer-user-id', plaintext); +const wire = serializePayload(payload); // send over DDP / WebSocket / REST + +// Decrypt (other side) +const received = deserializePayload(wire); +const result = await sessionMgr.decrypt('peer-user-id', received); +console.log(new TextDecoder().decode(result.plaintext)); +``` + +## Group Messaging (Conceptual) + +Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. + +## Error Handling + +All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. + +## Extending / Hardening + +Production needs (out of scope of this skeleton): +- Skipped message key handling & storage +- Full DH ratchet step logic & header validation +- Signature verification of pre-key bundles +- Authentic secondary MAC with independent key material +- More robust replay filtering (e.g. bloom filter) +- Formal test vectors & interoperability tests + +## License +MIT diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json new file mode 100644 index 0000000000000..59edb7bc4f982 --- /dev/null +++ b/packages/e2ee-web/package.json @@ -0,0 +1,41 @@ +{ + "name": "@rocket.chat/e2ee-web", + "private": true, + "version": "0.1.0", + "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "sideEffects": false, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", + "typecheck": "tsc -p tsconfig.json", + "lint": "eslint 'src/**/*.ts'", + "testunit": "vitest run" + }, + "dependencies": { + "@rocket.chat/e2ee": "workspace:^" + }, + "devDependencies": { + "@vitest/browser": "4.0.0-beta.8", + "playwright": "^1.54.2", + "typescript": "~5.9.2", + "vitest": "4.0.0-beta.8" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png b/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png new file mode 100644 index 0000000000000000000000000000000000000000..fc51a92c7ed5c223adb6aa2cb13c7399be9ef0f1 GIT binary patch literal 5807 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1{4u_pmYRCF%}28J29*~C-V}>aY>Ev zO!M_+&;qhK7#Q0#8CXC{fLIEM85o!sFfuR$X-1IP0w$O&qwfM{7@O$=NTGW1#Rvul zF-cDs$B+ufw^uguGAQt{7$*Fm%V@}W-TG^ISV{4H={^QVCYF6H*RG%H0I}vZWK^** zv2d_3UgU{b#=;>W;A`*Dz|g3mFpKf@6rhSlFWET+6a-jYj1y)GDL6Qo`~_)iYRF*I z2?pxxSSGLF(BL2-aJC`UtAT;>;umJ1AskGLBschQ3II*10}3)ZI+!p|(*){sxy&!1 z;Gn?Kk#@jH)uEvw;}=kH)bi0_A5CzhS#7kC87*E%E0)nJb+j=t+Kd`)K#n$@M>`Fp z-KEiv;%FCnbYx<5OlEX6YIGcKbOe&3u~)t*8O}M2VrA}u28%si{an^LB{Ts5F0Z~t literal 0 HcmV?d00001 diff --git a/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png b/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png new file mode 100644 index 0000000000000000000000000000000000000000..639feaa8cf3a0d79451bdd7280317040babc4edf GIT binary patch literal 5714 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1{4u_pmcGBUBaT;>;0a8TgrNIPJp>d?@T@e3%(!NPcvCt?{;pMbAD$UvYr z#?w=P`WC%p=MYd3U~w@{m?@;-;9&9>D9F;(kin)C4Aj@LOkTmE!9hUaY(uIS(4LE5 zm`5!i4ffFlH=5N(3z^a4b+lp`tx`uD6Qj+j(FWvb(|NShFxp)j?I@0Rkw-@+M#p4E lN25l^;YLRwDI9y1DdDp)v$|PP2^=eC@O1TaS?83{1OOaHv|IoH literal 0 HcmV?d00001 diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts new file mode 100644 index 0000000000000..dbd67f3ea4e8b --- /dev/null +++ b/packages/e2ee-web/src/__tests__/e2ee.test.ts @@ -0,0 +1,24 @@ +import { test, assert } from 'vitest'; + +import { WebE2EE } from '../index.ts'; +import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; + +class MockedKeyService implements KeyService { + fetchMyKeys(): Promise { + return Promise.resolve({ + public_key: 'mocked_public_key', + private_key: 'mocked_private_key', + }); + } +} + +test('E2EE createRandomPassword deterministic generation with 5 words', async () => { + // Inject custom word list by temporarily defining dynamic import. + // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. + // So we skip testing exact phrase (depends on wordList) and just assert shape. + const mockedKeyService = new MockedKeyService(); + const e2ee = WebE2EE.withMemoryStorage(mockedKeyService); + const pwd = await e2ee.createRandomPassword(); + assert.equal(pwd.split(' ').length, 5); + assert.equal(await e2ee.getRandomPassword(), pwd); +}); diff --git a/packages/e2ee-web/src/__tests__/keycodec.test.ts b/packages/e2ee-web/src/__tests__/keycodec.test.ts new file mode 100644 index 0000000000000..fca43b9be356c --- /dev/null +++ b/packages/e2ee-web/src/__tests__/keycodec.test.ts @@ -0,0 +1,43 @@ +import { test, expect } from 'vitest'; +import { WebKeyCodec } from '../keyCodec.ts'; + +const createKeyCodec = () => new WebKeyCodec(); + +test('KeyCodec roundtrip (v1 structured encoding)', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); + const dec = await codec.decodePrivateKey(enc, masterKey); + expect(dec).toBe(privateJWK); +}); + +test('KeyCodec wrong password fails', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const salt1 = new TextEncoder().encode('salt1'); + const masterKey = await codec.deriveMasterKey('correct', salt1); + const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); + await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); +}); + +test('KeyCodec tamper detection (ciphertext)', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + // const privStr = JSON.stringify(privateJWK); + const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); + const enc = await codec.encodePrivateKey(privateJWK, masterKey); + // Tamper with ctB64 + const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; + await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); +}); + +test('KeyCodec legacy roundtrip', async () => { + const codec = createKeyCodec(); + const { privateJWK } = await codec.generateRSAKeyPair(); + const privStr = JSON.stringify(privateJWK); + const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); + const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); + expect(dec).toBe(privateJWK); +}); diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts new file mode 100644 index 0000000000000..5af18c1b93e68 --- /dev/null +++ b/packages/e2ee-web/src/index.ts @@ -0,0 +1,29 @@ +import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; +import { WebKeyCodec } from './keyCodec.ts'; + +class MemoryStorage implements KeyStorage { + private map = new Map(); + + load(keyName: string): Promise { + return Promise.resolve(this.map.get(keyName) ?? null); + } + store(keyName: string, value: string): Promise { + this.map.set(keyName, value); + return Promise.resolve(); + } + remove(keyName: string): Promise { + this.map.delete(keyName); + return Promise.resolve(); + } +} + +export class WebE2EE extends BaseE2EE { + constructor(keyStorage: KeyStorage, keyService: KeyService) { + super(new WebKeyCodec(), keyStorage, keyService); + } + + static withMemoryStorage(keyService: KeyService): WebE2EE { + const memoryStorage = new MemoryStorage(); + return new WebE2EE(memoryStorage, keyService); + } +} diff --git a/packages/e2ee-web/src/keyCodec.ts b/packages/e2ee-web/src/keyCodec.ts new file mode 100644 index 0000000000000..86bc1de5056ab --- /dev/null +++ b/packages/e2ee-web/src/keyCodec.ts @@ -0,0 +1,38 @@ +import { BaseKeyCodec } from '@rocket.chat/e2ee'; + +export class WebKeyCodec extends BaseKeyCodec { + constructor() { + super({ + exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), + getRandomUint8Array: (array) => crypto.getRandomValues(array), + getRandomUint16Array: (array) => crypto.getRandomValues(array), + getRandomUint32Array: (array) => crypto.getRandomValues(array), + importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + generateKeyPair: () => + crypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ), + decodeBase64: (input) => { + const binaryString = atob(input); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + }, + encodeBase64: (input) => { + const bytes = new Uint8Array(input); + let binaryString = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binaryString += String.fromCharCode(bytes[i]!); + } + return btoa(binaryString); + }, + decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + }); + } +} diff --git a/packages/e2ee-web/tsconfig.build.json b/packages/e2ee-web/tsconfig.build.json new file mode 100644 index 0000000000000..cf4278ae09d96 --- /dev/null +++ b/packages/e2ee-web/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/e2ee-web/tsconfig.json b/packages/e2ee-web/tsconfig.json new file mode 100644 index 0000000000000..d6452eb16762b --- /dev/null +++ b/packages/e2ee-web/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "composite": false, + "module": "node20", + "target": "ES2024", + "types": [], + "lib": ["ES2024","DOM"], + + /** Emit */ + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + + /** Language and Environment */ + "libReplacement": false, + + /** Linting */ + "strict": true, + "erasableSyntaxOnly": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "isolatedModules": true, + "isolatedDeclarations": true, + + "allowJs": false, + "esModuleInterop": false, + "skipLibCheck": false + }, + "include": ["src"], + "exclude": [] +} diff --git a/packages/e2ee-web/vitest.config.ts b/packages/e2ee-web/vitest.config.ts new file mode 100644 index 0000000000000..927b95189a1cf --- /dev/null +++ b/packages/e2ee-web/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + browser: { + fileParallelism: false, + enabled: true, + provider: 'playwright', + headless: true, + // https://vitest.dev/guide/browser/playwright + instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], + }, + }, +}); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index e12e7485c3ff7..1a4b4dbd1c6a6 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,4 +1,6 @@ -export { KeyCodec } from './keyCodec.ts'; +import type { BaseKeyCodec } from './keyCodec.ts'; + +export { BaseKeyCodec } from './keyCodec.ts'; export interface KeyStorage { load(keyName: string): Promise; @@ -10,10 +12,6 @@ export interface KeyService { fetchMyKeys(): Promise; } -export interface CryptoProvider { - getRandomValues(array: Uint32Array): Uint32Array; -} - export type PrivateKey = string; export type PublicKey = string; export type KeyPair = { @@ -21,12 +19,12 @@ export type KeyPair = { private_key: PrivateKey; }; -export abstract class E2EEBase { +export abstract class BaseE2EE { #service: KeyService; #storage: KeyStorage; - #crypto: CryptoProvider; + #crypto: BaseKeyCodec; - constructor(crypto: CryptoProvider, storage: KeyStorage, service: KeyService) { + constructor(crypto: BaseKeyCodec, storage: KeyStorage, service: KeyService) { this.#storage = storage; this.#service = service; this.#crypto = crypto; @@ -51,17 +49,6 @@ export abstract class E2EEBase { return keys; } - async #generateMnemonicPhrase(n: number, sep = ' '): Promise { - const wordList = await import('./wordList.ts'); - const result = new Array(n); - const randomBuffer = new Uint32Array(n); - this.#crypto.getRandomValues(randomBuffer); - for (let i = 0; i < n; i++) { - result[i] = wordList.v1[randomBuffer[i]! % wordList.v1.length]!; - } - return result.join(sep); - } - async storeRandomPassword(randomPassword: string): Promise { await this.#storage.store('e2e.random_password', randomPassword); } @@ -75,7 +62,7 @@ export abstract class E2EEBase { } async createRandomPassword(): Promise { - const randomPassword = await this.#generateMnemonicPhrase(5); + const randomPassword = await this.#crypto.generateMnemonicPhrase(5); this.storeRandomPassword(randomPassword); return randomPassword; } diff --git a/packages/e2ee/src/keyCodec.ts b/packages/e2ee/src/keyCodec.ts index 354dbf1b8217c..f2db12bc0cea6 100644 --- a/packages/e2ee/src/keyCodec.ts +++ b/packages/e2ee/src/keyCodec.ts @@ -1,7 +1,9 @@ export interface CryptoProvider { exportJsonWebKey(key: CryptoKey): Promise; - importRawKey(raw: ArrayBuffer | Uint8Array): Promise; - getRandomValues(array: Uint8Array): Uint8Array; + importRawKey(raw: Uint8Array): Promise; + getRandomUint8Array(array: Uint8Array): Uint8Array; + getRandomUint16Array(array: Uint16Array): Uint16Array; + getRandomUint32Array(array: Uint32Array): Uint32Array; generateKeyPair(): Promise; decodeBase64(input: string): Uint8Array; encodeBase64(input: ArrayBuffer): string; @@ -22,7 +24,7 @@ export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible di const ITERATIONS = 1000; // mirrors legacy implementation -export class KeyCodec { +export abstract class BaseKeyCodec { #crypto: CryptoProvider; constructor(deps: CryptoProvider) { @@ -30,17 +32,21 @@ export class KeyCodec { } async generateRSAKeyPair(): Promise<{ publicJWK: JsonWebKey; privateJWK: JsonWebKey }> { - const keyPair = await this.#crypto.generateKeyPair(); - const publicJWK = await this.#crypto.exportJsonWebKey(keyPair.publicKey); - const privateJWK = await this.#crypto.exportJsonWebKey(keyPair.privateKey); + const { generateKeyPair, exportJsonWebKey } = this.#crypto; + + const keyPair = await generateKeyPair(); + const publicJWK = await exportJsonWebKey(keyPair.publicKey); + const privateJWK = await exportJsonWebKey(keyPair.privateKey); return { publicJWK, privateJWK }; } - async encodePrivateKey(privateKeyJwkString: string, masterKey: CryptoKey): Promise { - const { getRandomValues, encodeBase64, encryptAesCbc } = this.#crypto; - const iv = getRandomValues(new Uint8Array(16)); - const data = new TextEncoder().encode(privateKeyJwkString); + async encodePrivateKey(privateKeyJwk: JsonWebKey, masterKey: CryptoKey): Promise { + const { getRandomUint8Array, encodeBase64, encryptAesCbc } = this.#crypto; + + const iv = getRandomUint8Array(new Uint8Array(16)); + const data = new TextEncoder().encode(JSON.stringify(privateKeyJwk)); const ct = await encryptAesCbc(masterKey, iv, data); + return { v: 1, mode: 'AES-CBC', @@ -50,6 +56,19 @@ export class KeyCodec { }; } + async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { + if (encoded.v !== 1) throw new Error(`Unsupported encoded private key version: ${encoded.v}`); + if (encoded.mode !== 'AES-CBC') throw new Error(`Unsupported cipher mode: ${encoded.mode}`); + const iv = this.#crypto.decodeBase64(encoded.ivB64); + const ct = this.#crypto.decodeBase64(encoded.ctB64); + try { + const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); + return JSON.parse(new TextDecoder().decode(plain)); + } catch (e) { + throw new Error('Failed to decrypt private key'); + } + } + async deriveMasterKey(password: string, salt: Uint8Array): Promise { if (!password) throw new Error('Password is required'); const enc = new TextEncoder(); @@ -63,25 +82,12 @@ export class KeyCodec { ); } - async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { - if (encoded.v !== 1) throw new Error(`Unsupported encoded private key version: ${encoded.v}`); - if (encoded.mode !== 'AES-CBC') throw new Error(`Unsupported cipher mode: ${encoded.mode}`); - const iv = this.#crypto.decodeBase64(encoded.ivB64); - const ct = this.#crypto.decodeBase64(encoded.ctB64); - try { - const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return new TextDecoder().decode(plain); - } catch (e) { - throw new Error('Failed to decrypt private key'); - } - } - // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { - const { getRandomValues } = this.#crypto; + const { getRandomUint8Array } = this.#crypto; const salt = new TextEncoder().encode(saltStr); const masterKey = await this.deriveMasterKey(password, salt); - const iv = getRandomValues(new Uint8Array(16)); + const iv = getRandomUint8Array(new Uint8Array(16)); const data = new TextEncoder().encode(privateKeyJwkString); const ct = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, masterKey, data); const ctBytes = new Uint8Array(ct); @@ -99,10 +105,21 @@ export class KeyCodec { const salt = new TextEncoder().encode(saltStr); const masterKey = await this.deriveMasterKey(password, salt); try { - const plain = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, masterKey, ct); - return new TextDecoder().decode(plain, { stream: false }); + const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); + return new TextDecoder().decode(plain); } catch (e) { throw new Error('Failed to decrypt legacy private key'); } } + + async generateMnemonicPhrase(n: number, sep = ' '): Promise { + const wordList = await import('./wordList.ts'); + const result = new Array(n); + const randomBuffer = new Uint32Array(n); + this.#crypto.getRandomUint32Array(randomBuffer); + for (let i = 0; i < n; i++) { + result[i] = wordList.v1[randomBuffer[i]! % wordList.v1.length]!; + } + return result.join(sep); + } } diff --git a/yarn.lock b/yarn.lock index 2178a52ee1800..500b9c3ad4411 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4954,6 +4954,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.29 + resolution: "@polka/url@npm:1.0.0-next.29" + checksum: 10/69ca11ab15a4ffec7f0b07fcc4e1f01489b3d9683a7e1867758818386575c60c213401259ba3705b8a812228d17e2bfd18e6f021194d943fff4bca389c9d4f28 + languageName: node + linkType: hard + "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -7260,6 +7267,18 @@ __metadata: languageName: unknown linkType: soft +"@rocket.chat/e2ee-web@workspace:packages/e2ee-web": + version: 0.0.0-use.local + resolution: "@rocket.chat/e2ee-web@workspace:packages/e2ee-web" + dependencies: + "@rocket.chat/e2ee": "workspace:^" + "@vitest/browser": "npm:4.0.0-beta.8" + playwright: "npm:^1.54.2" + typescript: "npm:~5.9.2" + vitest: "npm:4.0.0-beta.8" + languageName: unknown + linkType: soft + "@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": version: 0.0.0-use.local resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" @@ -9416,6 +9435,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.46.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-android-arm64@npm:4.34.4" @@ -9423,6 +9449,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-android-arm64@npm:4.46.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-darwin-arm64@npm:4.34.4" @@ -9430,6 +9463,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-darwin-arm64@npm:4.46.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-darwin-x64@npm:4.34.4" @@ -9437,6 +9477,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-darwin-x64@npm:4.46.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-freebsd-arm64@npm:4.34.4" @@ -9444,6 +9491,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.46.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-freebsd-x64@npm:4.34.4" @@ -9451,6 +9505,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-freebsd-x64@npm:4.46.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4" @@ -9458,6 +9519,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.46.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.34.4" @@ -9465,6 +9533,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.46.2" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.34.4" @@ -9472,6 +9547,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.46.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.34.4" @@ -9479,6 +9561,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.46.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4" @@ -9486,6 +9575,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-loongarch64-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.46.2" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4" @@ -9493,6 +9589,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.46.2" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.34.4" @@ -9500,6 +9603,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.46.2" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.46.2" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.34.4" @@ -9507,6 +9624,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.46.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.34.4" @@ -9514,6 +9638,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.46.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-x64-musl@npm:4.34.4" @@ -9521,6 +9652,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.46.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.34.4" @@ -9528,6 +9666,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.46.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.34.4" @@ -9535,6 +9680,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.46.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.34.4" @@ -9542,6 +9694,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.46.2": + version: 4.46.2 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.46.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -10663,6 +10822,22 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.3.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" + pretty-format: "npm:^27.0.2" + checksum: 10/7f93e09ea015f151f8b8f42cbab0b2b858999b5445f15239a72a612ef7716e672b14c40c421218194cf191cbecbde0afa6f3dc2cc83dda93ff6a4fb0237df6e6 + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:6.5.0": version: 6.5.0 resolution: "@testing-library/jest-dom@npm:6.5.0" @@ -10742,6 +10917,15 @@ __metadata: languageName: node linkType: hard +"@testing-library/user-event@npm:^14.6.1": + version: 14.6.1 + resolution: "@testing-library/user-event@npm:14.6.1" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10/34b74fff56a0447731a94b40d4cf246deb8dbc1c1e3aec93acd1c3377a760bb062e979f1572bb34ec164ad28ee2a391744b42d0d6d6cc16c4ce527e5e09610e1 + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -10970,6 +11154,15 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.2": + version: 5.2.2 + resolution: "@types/chai@npm:5.2.2" + dependencies: + "@types/deep-eql": "npm:*" + checksum: 10/de425e7b02cc1233a93923866e019dffbafa892774813940b780ebb1ac9f8a8c57b7438c78686bf4e5db05cd3fc8a970fedf6b83638543995ecca88ef2060668 + languageName: node + linkType: hard + "@types/chalk@npm:^2.2.4": version: 2.2.4 resolution: "@types/chalk@npm:2.2.4" @@ -11362,6 +11555,13 @@ __metadata: languageName: node linkType: hard +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10/249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c + languageName: node + linkType: hard + "@types/doctrine@npm:^0.0.9": version: 0.0.9 resolution: "@types/doctrine@npm:0.0.9" @@ -11419,6 +11619,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10/25a4c16a6752538ffde2826c2cc0c6491d90e69cd6187bef4a006dd2c3c45469f049e643d7e516c515f21484dc3d48fd5c870be158a5beb72f5baf3dc43e4099 + languageName: node + linkType: hard + "@types/express-rate-limit@npm:^5.1.3": version: 5.1.3 resolution: "@types/express-rate-limit@npm:5.1.3" @@ -13050,6 +13257,35 @@ __metadata: languageName: node linkType: hard +"@vitest/browser@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/browser@npm:4.0.0-beta.8" + dependencies: + "@testing-library/dom": "npm:^10.4.1" + "@testing-library/user-event": "npm:^14.6.1" + "@vitest/mocker": "npm:4.0.0-beta.8" + "@vitest/utils": "npm:4.0.0-beta.8" + magic-string: "npm:^0.30.17" + pixelmatch: "npm:7.1.0" + pngjs: "npm:^7.0.0" + sirv: "npm:^3.0.1" + tinyrainbow: "npm:^2.0.0" + ws: "npm:^8.18.3" + peerDependencies: + playwright: "*" + vitest: 4.0.0-beta.8 + webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + checksum: 10/8e8897d4fdc9f3436e7f6c7b72ca02d71bb95ef4fed6dd6f7464c9ece2cc077ffed79f6e0c6d072a399fd5238ade97f01822c19fd00bc58e7aecc2dc3b8f5b01 + languageName: node + linkType: hard + "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -13062,6 +13298,38 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/expect@npm:4.0.0-beta.8" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.0.0-beta.8" + "@vitest/utils": "npm:4.0.0-beta.8" + chai: "npm:^5.2.1" + tinyrainbow: "npm:^2.0.0" + checksum: 10/18380a9a3f328d7832db84803fe45c7fb668b190522d0763adfebefb10e9af00c5f33e06535574915a9deda5fb32960f60483e49137e36b9b585723e02cd8dff + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/mocker@npm:4.0.0-beta.8" + dependencies: + "@vitest/spy": "npm:4.0.0-beta.8" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10/3942f71062bf7c5ae6cb7c04888d84eb88a49280aa7c002304f60c726f081da24059cf8beb5e717e838379008d9bc88aee2d30623d96e7e8f82e26ff219ff555 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" @@ -13080,6 +13348,37 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.0.0-beta.8, @vitest/pretty-format@npm:^4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/pretty-format@npm:4.0.0-beta.8" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10/f13716c42c3dcadcb395a2b3f38e9ca28192e22dde024be833911088acfa6e29dd13bc6d1e0768a6a8181e6861a5296b8f24a7b8af56e3fab8c353194ac32ce7 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/runner@npm:4.0.0-beta.8" + dependencies: + "@vitest/utils": "npm:4.0.0-beta.8" + pathe: "npm:^2.0.3" + strip-literal: "npm:^3.0.0" + checksum: 10/6f6b24e3aa8866c506b1cc956936a9feba2e9e88465c5dd18c00709a1839c4ee4570b38ed27c299047c03dcc32de1b91a71ef0d5d95cab9d992052e32a455e2b + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/snapshot@npm:4.0.0-beta.8" + dependencies: + "@vitest/pretty-format": "npm:4.0.0-beta.8" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10/d1d6d3aa9939c8a7abd07ef88f648cff762b71f981b8cd6625759105c992b0b400c168ec49aa77992a83da3263795355dbcc967d14108acf41513ccb78089ea9 + languageName: node + linkType: hard + "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -13089,6 +13388,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/spy@npm:4.0.0-beta.8" + checksum: 10/71a0d4addbb100e4342aa0fc15b297bb1e04536543d2ce13442da328a94b5eb37b606af53adee06cee795c03faa21b84c3919ac2b0377d198c44f9025b193c5e + languageName: node + linkType: hard + "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -13101,6 +13407,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "@vitest/utils@npm:4.0.0-beta.8" + dependencies: + "@vitest/pretty-format": "npm:4.0.0-beta.8" + loupe: "npm:^3.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10/1a9ee2bce6e6ca1b62c7fa96cb79d0511090ddf1e6dd8743d211f060bae92e2e69bb713508e447f6f9bf23f3f3acc9a06b775677d8b433af1af47a88e960802f + languageName: node + linkType: hard + "@vitest/utils@npm:^2.1.1": version: 2.1.4 resolution: "@vitest/utils@npm:2.1.4" @@ -15753,6 +16070,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.2.1": + version: 5.2.1 + resolution: "chai@npm:5.2.1" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10/2d9b14c9bbb9b791641ecee0d74a53f34d2c86f05c10b5567041ed376177f87af9dc7f5398207fd2711affbfeb9f0f1f60e11bcaf021f78ef37b7c65a6d94e7d + languageName: node + linkType: hard + "chalk@npm:*, chalk@npm:^5.2.0": version: 5.4.1 resolution: "chalk@npm:5.4.1" @@ -17575,6 +17905,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.1": + version: 4.4.1 + resolution: "debug@npm:4.4.1" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + languageName: node + linkType: hard + "debug@npm:~3.1.0": version: 3.1.0 resolution: "debug@npm:3.1.0" @@ -18929,6 +19271,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10/b6f3e576a3fed4d82b0d0ad4bbf6b3a5ad694d2e7ce8c4a069560da3db6399381eaba703616a182b16dde50ce998af64e07dcf49f2ae48153b9e07be3f107087 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -19775,6 +20124,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.2.2": + version: 1.2.2 + resolution: "expect-type@npm:1.2.2" + checksum: 10/1703e6e47b575f79d801d87f24c639f4d0af71b327a822e6922d0ccb7eb3f6559abb240b8bd43bab6a477903de4cc322908e194d05132c18f52a217115e8e870 + languageName: node + linkType: hard + "expect@npm:30.0.5, expect@npm:^30.0.0": version: 30.0.5 resolution: "expect@npm:30.0.5" @@ -20075,6 +20431,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.4, fdir@npm:^6.4.6": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1 + languageName: node + linkType: hard + "fecha@npm:^4.2.0": version: 4.2.3 resolution: "fecha@npm:4.2.3" @@ -24695,6 +25063,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 10/3288ba73bb2023adf59501979fb4890feb6669cc167b13771b226814fde96a1583de3989249880e3f4d674040d1815685db9a9880db9153307480d39dc760365 + languageName: node + linkType: hard + "js-yaml@npm:4.1.0, js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -25693,6 +26068,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.2.0": + version: 3.2.0 + resolution: "loupe@npm:3.2.0" + checksum: 10/80d48e35b014c2ba5886e25a02ee4cc9c1f659b0eca9c4fa8f07051cdc689e0507a763fbe05a63abbd3b7d4640774a722c905d4b1681b4b92c3ba8f87d96fea2 + languageName: node + linkType: hard + "lowdb@npm:1": version: 1.0.0 resolution: "lowdb@npm:1.0.0" @@ -26985,6 +27367,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10/1f966e2c05b7264209c4149ae50e8e830908eb64dd903535196f6ad72681fa109b794007288a3c2814f7a1ecf9ca192769909c0c374d974d604a8de5fc095d4a + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -28619,6 +29008,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10/01e9a69928f39087d96e1751ce7d6d50da8c39abf9a12e0ac2389c42c83bc76f78c45a475bd9026a02e6a6f79be63acc75667df855862fe567d99a00a540d23d + languageName: node + linkType: hard + "pathington@npm:^1.1.7": version: 1.1.7 resolution: "pathington@npm:1.1.7" @@ -28703,6 +29099,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 + languageName: node + linkType: hard + "picocolors@npm:^0.2.1": version: 0.2.1 resolution: "picocolors@npm:0.2.1" @@ -28710,13 +29113,6 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": - version: 1.1.1 - resolution: "picocolors@npm:1.1.1" - checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 - languageName: node - linkType: hard - "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -28731,6 +29127,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 + languageName: node + linkType: hard + "pidtree@npm:^0.3.0": version: 0.3.1 resolution: "pidtree@npm:0.3.1" @@ -28855,6 +29258,17 @@ __metadata: languageName: node linkType: hard +"pixelmatch@npm:7.1.0": + version: 7.1.0 + resolution: "pixelmatch@npm:7.1.0" + dependencies: + pngjs: "npm:^7.0.0" + bin: + pixelmatch: bin/pixelmatch + checksum: 10/57a122196318ea8ce74e8759b1b7b94b9f9627b495cd79e50a49d470dc23b6c679e89c38660d0f7e8f959eac3b279c55b728e52d02c276dc51505f06eaba1141 + languageName: node + linkType: hard + "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -28898,6 +29312,15 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.54.2": + version: 1.54.2 + resolution: "playwright-core@npm:1.54.2" + bin: + playwright-core: cli.js + checksum: 10/8eb8b37d7b02c2394f85567c5703464ee7c85929e1de7118946548a0277cacd41a6f247b8f7eb50c921433346f785356eff8132a9d3c6c2dcf3402c6a83e4f18 + languageName: node + linkType: hard + "playwright-qase-reporter@npm:^2.1.3": version: 2.1.3 resolution: "playwright-qase-reporter@npm:2.1.3" @@ -28926,6 +29349,21 @@ __metadata: languageName: node linkType: hard +"playwright@npm:^1.54.2": + version: 1.54.2 + resolution: "playwright@npm:1.54.2" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.54.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/3f4fedc1e2290b6da6960eedf0d78b9097251848b499b0627186ca85e94178fe6be1590c4926246ff4dad5729fc80ab63fee6554fe736d29c0a1808ec483467f + languageName: node + linkType: hard + "please-upgrade-node@npm:^3.2.0": version: 3.2.0 resolution: "please-upgrade-node@npm:3.2.0" @@ -28935,6 +29373,13 @@ __metadata: languageName: node linkType: hard +"pngjs@npm:^7.0.0": + version: 7.0.0 + resolution: "pngjs@npm:7.0.0" + checksum: 10/e843ebbb0df092ee0f3a3e7dbd91ff87a239a4e4c4198fff202916bfb33b67622f4b83b3c29f3ccae94fcb97180c289df06068624554f61686fe6b9a4811f7db + languageName: node + linkType: hard + "pngquant-bin@npm:^6.0.0": version: 6.0.1 resolution: "pngquant-bin@npm:6.0.1" @@ -31795,6 +32240,81 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.43.0": + version: 4.46.2 + resolution: "rollup@npm:4.46.2" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.46.2" + "@rollup/rollup-android-arm64": "npm:4.46.2" + "@rollup/rollup-darwin-arm64": "npm:4.46.2" + "@rollup/rollup-darwin-x64": "npm:4.46.2" + "@rollup/rollup-freebsd-arm64": "npm:4.46.2" + "@rollup/rollup-freebsd-x64": "npm:4.46.2" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.46.2" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.46.2" + "@rollup/rollup-linux-arm64-gnu": "npm:4.46.2" + "@rollup/rollup-linux-arm64-musl": "npm:4.46.2" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.46.2" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.46.2" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.46.2" + "@rollup/rollup-linux-riscv64-musl": "npm:4.46.2" + "@rollup/rollup-linux-s390x-gnu": "npm:4.46.2" + "@rollup/rollup-linux-x64-gnu": "npm:4.46.2" + "@rollup/rollup-linux-x64-musl": "npm:4.46.2" + "@rollup/rollup-win32-arm64-msvc": "npm:4.46.2" + "@rollup/rollup-win32-ia32-msvc": "npm:4.46.2" + "@rollup/rollup-win32-x64-msvc": "npm:4.46.2" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loongarch64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10/3acc425a9828e8ba75eaec9cb506a680d3deaa09a753635140cc4a3c98445ba6e3f631b32ca1c98965b3f60672d2815cd560ea844bba497d69e65c58c02327cf + languageName: node + linkType: hard + "rrweb-cssom@npm:^0.8.0": version: 0.8.0 resolution: "rrweb-cssom@npm:0.8.0" @@ -32494,6 +33014,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10/e93ff66c6531a079af8fb217240df01f980155b5dc408d2d7bebc398dd284e383eb318153bf8acd4db3c4fe799aa5b9a641e38b0ba3b1975700b1c89547ea4e7 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -32556,6 +33083,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^3.0.1": + version: 3.0.1 + resolution: "sirv@npm:3.0.1" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10/b110ebe28eb1740772fbbfacb6c71c58d1ec8ec17a5ae2852a5418c3ef41d52d473663613de808f8a6337ec29dd446414d0d059e75bfd13fb9630d18651c99f2 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -33035,6 +33573,13 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10/2d4dc4e64e2db796de4a3c856d5943daccdfa3dd092e452a1ce059c81e9a9c29e0b9badba91b43ef0d5ff5c04ee62feb3bcc559a804e16faf447bac2d883aa99 + languageName: node + linkType: hard + "stackframe@npm:^1.1.1": version: 1.2.1 resolution: "stackframe@npm:1.2.1" @@ -33056,6 +33601,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.9.0": + version: 3.9.0 + resolution: "std-env@npm:3.9.0" + checksum: 10/3044b2c54a74be4f460db56725571241ab3ac89a91f39c7709519bc90fa37148784bc4cd7d3a301aa735f43bd174496f263563f76703ce3e81370466ab7c235b + languageName: node + linkType: hard + "stealthy-require@npm:^1.1.1": version: 1.1.1 resolution: "stealthy-require@npm:1.1.1" @@ -33504,6 +34056,15 @@ __metadata: languageName: node linkType: hard +"strip-literal@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-literal@npm:3.0.0" + dependencies: + js-tokens: "npm:^9.0.1" + checksum: 10/da1616f654f3ff481e078597b4565373a5eeed78b83de4a11a1a1b98292a9036f2474e528eff19b6eed93370428ff957a473827057c117495086436725d7efad + languageName: node + linkType: hard + "strip-outer@npm:^1.0.0, strip-outer@npm:^1.0.1": version: 1.0.1 resolution: "strip-outer@npm:1.0.1" @@ -34275,6 +34836,30 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10/cfa1e1418e91289219501703c4693c70708c91ffb7f040fd318d24aef419fb5a43e0c0160df9471499191968b2451d8da7f8087b08c3133c251c40d24aced06c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.2": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10/b9d5fed3166fb1acd1e7f9a89afcd97ccbe18b9c1af0278e429455f6976d69271ba2d21797e7c36d57d6b05025e525d2882d88c2ab435b60d1ddf2fea361de57 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.14": + version: 0.2.14 + resolution: "tinyglobby@npm:0.2.14" + dependencies: + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10/3d306d319718b7cc9d79fb3f29d8655237aa6a1f280860a217f93417039d0614891aee6fc47c5db315f4fcc6ac8d55eb8e23e2de73b2c51a431b42456d9e5764 + languageName: node + linkType: hard + "tinykeys@npm:^1.4.0": version: 1.4.0 resolution: "tinykeys@npm:1.4.0" @@ -34282,6 +34867,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^1.1.1": + version: 1.1.1 + resolution: "tinypool@npm:1.1.1" + checksum: 10/0d54139e9dbc6ef33349768fa78890a4d708d16a7ab68e4e4ef3bb740609ddf0f9fd13292c2f413fbba756166c97051a657181c8f7ae92ade690604f183cc01d + languageName: node + linkType: hard + "tinyrainbow@npm:^1.2.0": version: 1.2.0 resolution: "tinyrainbow@npm:1.2.0" @@ -34289,6 +34881,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10/94d4e16246972614a5601eeb169ba94f1d49752426312d3cf8cc4f2cc663a2e354ffc653aa4de4eebccbf9eeebdd0caef52d1150271fdfde65d7ae7f3dcb9eb5 + languageName: node + linkType: hard + "tinyspy@npm:^3.0.0": version: 3.0.2 resolution: "tinyspy@npm:3.0.2" @@ -34397,6 +34996,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10/5132d562cf88ff93fd710770a92f31dbe67cc19b5c6ccae2efc0da327f0954d211bbfd9456389655d726c624f284b4a23112f56d1da931ca7cfabbe1f45e778a + languageName: node + linkType: hard + "tough-cookie@npm:^2.3.3, tough-cookie@npm:~2.5.0": version: 2.5.0 resolution: "tough-cookie@npm:2.5.0" @@ -35782,6 +36388,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:^6.0.0 || ^7.0.0-0": + version: 7.1.2 + resolution: "vite@npm:7.1.2" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.4.6" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.14" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10/942188896db181bd5f95c576303eb617cd08580eb129d0223bc87a8ed9b8dc0d60d8487c077fd9a01087c55600af132c5fc677e51b4b48a4a54a376056969edb + languageName: node + linkType: hard + "vite@npm:^6.2.4": version: 6.2.4 resolution: "vite@npm:6.2.4" @@ -35834,6 +36495,62 @@ __metadata: languageName: node linkType: hard +"vitest@npm:4.0.0-beta.8": + version: 4.0.0-beta.8 + resolution: "vitest@npm:4.0.0-beta.8" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/expect": "npm:4.0.0-beta.8" + "@vitest/mocker": "npm:4.0.0-beta.8" + "@vitest/pretty-format": "npm:^4.0.0-beta.8" + "@vitest/runner": "npm:4.0.0-beta.8" + "@vitest/snapshot": "npm:4.0.0-beta.8" + "@vitest/spy": "npm:4.0.0-beta.8" + "@vitest/utils": "npm:4.0.0-beta.8" + chai: "npm:^5.2.1" + debug: "npm:^4.4.1" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.9.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.14" + tinypool: "npm:^1.1.1" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^6.0.0 || ^7.0.0-0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 4.0.0-beta.8 + "@vitest/ui": 4.0.0-beta.8 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10/04ea772066e157d79a4202d8b360483f7c2114bcd5ea9920322f043fc0ca14c482031d84476d922c772397da2a1bc824f12a5002bd590f1f50eb30ff7da03440 + languageName: node + linkType: hard + "vm-browserify@npm:^1.0.0, vm-browserify@npm:^1.1.2": version: 1.1.2 resolution: "vm-browserify@npm:1.1.2" @@ -36357,6 +37074,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10/0de6e6cd8f2f94a8b5ca44e84cf1751eadcac3ebedcdc6e5fbbe6c8011904afcbc1a2777c53496ec02ced7b81f2e7eda61e76bf8262a8bc3ceaa1f6040508051 + languageName: node + linkType: hard + "wide-align@npm:^1.1.2": version: 1.1.5 resolution: "wide-align@npm:1.1.5" @@ -36526,6 +37255,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.3": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 + languageName: node + linkType: hard + "xml-crypto@npm:~3.2.1": version: 3.2.1 resolution: "xml-crypto@npm:3.2.1" From 2b9f4c9bd6e1aa563ba12ee68313ef257545e0ba Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 14 Aug 2025 19:42:06 -0300 Subject: [PATCH 008/251] fix tests --- packages/e2ee-node/package.json | 5 +++-- packages/e2ee-node/src/__tests__/e2ee.test.ts | 4 ++-- .../e2ee-node/src/__tests__/keycodec.test.ts | 12 ++++++------ .../KeyCodec-legacy-roundtrip-1.png | Bin 5807 -> 0 bytes ...odec-roundtrip--v1-structured-encoding--1.png | Bin 5714 -> 0 bytes packages/e2ee-web/vitest.config.ts | 1 + yarn.lock | 1 + 7 files changed, 13 insertions(+), 10 deletions(-) delete mode 100644 packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png delete mode 100644 packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png diff --git a/packages/e2ee-node/package.json b/packages/e2ee-node/package.json index 23a1e31f299ab..2a1b3e84098dd 100644 --- a/packages/e2ee-node/package.json +++ b/packages/e2ee-node/package.json @@ -20,14 +20,15 @@ "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "eslint 'src/**/*.ts'", - "testunit": "node --experimental-strip-types --test" + "testunit": "vitest run" }, "dependencies": { "@rocket.chat/e2ee": "workspace:^" }, "devDependencies": { "@types/node": "~22.16.5", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "4.0.0-beta.8" }, "license": "MIT", "publishConfig": { diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index 65bdf8f06e6fc..17268659866df 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -1,5 +1,5 @@ -import { test } from 'node:test'; -import * as assert from 'node:assert/strict'; +import { test, assert } from 'vitest'; + import { NodeE2EE } from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; diff --git a/packages/e2ee-node/src/__tests__/keycodec.test.ts b/packages/e2ee-node/src/__tests__/keycodec.test.ts index 5bfdf94b1b560..f46582b4ef585 100644 --- a/packages/e2ee-node/src/__tests__/keycodec.test.ts +++ b/packages/e2ee-node/src/__tests__/keycodec.test.ts @@ -1,5 +1,4 @@ -import { test } from 'node:test'; -import * as assert from 'node:assert/strict'; +import { test, expect } from 'vitest'; import { NodeKeyCodec } from '../keyCodec.ts'; const createKeyCodec = () => new NodeKeyCodec(); @@ -10,7 +9,7 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); - assert.equal(dec, privateJWK); + expect(dec).toBe(privateJWK); }); test('KeyCodec wrong password fails', async () => { @@ -20,17 +19,18 @@ test('KeyCodec wrong password fails', async () => { const masterKey = await codec.deriveMasterKey('correct', salt1); const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); const enc = await codec.encodePrivateKey(privateJWK, masterKey); - await assert.rejects(() => codec.decodePrivateKey(enc, masterKey2)); + await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); }); test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); + // const privStr = JSON.stringify(privateJWK); const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); const enc = await codec.encodePrivateKey(privateJWK, masterKey); // Tamper with ctB64 const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; - await assert.rejects(() => codec.decodePrivateKey(mutated, masterKey)); + await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); }); test('KeyCodec legacy roundtrip', async () => { @@ -39,5 +39,5 @@ test('KeyCodec legacy roundtrip', async () => { const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); - assert.equal(dec, privStr); + expect(dec).toBe(privateJWK); }); diff --git a/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png b/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-legacy-roundtrip-1.png deleted file mode 100644 index fc51a92c7ed5c223adb6aa2cb13c7399be9ef0f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5807 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1{4u_pmYRCF%}28J29*~C-V}>aY>Ev zO!M_+&;qhK7#Q0#8CXC{fLIEM85o!sFfuR$X-1IP0w$O&qwfM{7@O$=NTGW1#Rvul zF-cDs$B+ufw^uguGAQt{7$*Fm%V@}W-TG^ISV{4H={^QVCYF6H*RG%H0I}vZWK^** zv2d_3UgU{b#=;>W;A`*Dz|g3mFpKf@6rhSlFWET+6a-jYj1y)GDL6Qo`~_)iYRF*I z2?pxxSSGLF(BL2-aJC`UtAT;>;umJ1AskGLBschQ3II*10}3)ZI+!p|(*){sxy&!1 z;Gn?Kk#@jH)uEvw;}=kH)bi0_A5CzhS#7kC87*E%E0)nJb+j=t+Kd`)K#n$@M>`Fp z-KEiv;%FCnbYx<5OlEX6YIGcKbOe&3u~)t*8O}M2VrA}u28%si{an^LB{Ts5F0Z~t diff --git a/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png b/packages/e2ee-web/src/__tests__/__screenshots__/keycodec.test.ts/KeyCodec-roundtrip--v1-structured-encoding--1.png deleted file mode 100644 index 639feaa8cf3a0d79451bdd7280317040babc4edf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5714 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1{4u_pmcGBUBaT;>;0a8TgrNIPJp>d?@T@e3%(!NPcvCt?{;pMbAD$UvYr z#?w=P`WC%p=MYd3U~w@{m?@;-;9&9>D9F;(kin)C4Aj@LOkTmE!9hUaY(uIS(4LE5 zm`5!i4ffFlH=5N(3z^a4b+lp`tx`uD6Qj+j(FWvb(|NShFxp)j?I@0Rkw-@+M#p4E lN25l^;YLRwDI9y1DdDp)v$|PP2^=eC@O1TaS?83{1OOaHv|IoH diff --git a/packages/e2ee-web/vitest.config.ts b/packages/e2ee-web/vitest.config.ts index 927b95189a1cf..71b9de22983f5 100644 --- a/packages/e2ee-web/vitest.config.ts +++ b/packages/e2ee-web/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { browser: { fileParallelism: false, + screenshotFailures: false, enabled: true, provider: 'playwright', headless: true, diff --git a/yarn.lock b/yarn.lock index 500b9c3ad4411..454c5278a8543 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7264,6 +7264,7 @@ __metadata: "@rocket.chat/e2ee": "workspace:^" "@types/node": "npm:~22.16.5" typescript: "npm:~5.9.2" + vitest: "npm:4.0.0-beta.8" languageName: unknown linkType: soft From 368aff92fa0a5f9b4f5109830cf24b36b1faf06b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 15 Aug 2025 16:47:07 -0300 Subject: [PATCH 009/251] fix tests and add linting --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 19 +- apps/meteor/package.json | 3 +- package.json | 1 + packages/e2ee-node/.gitignore | 1 - packages/e2ee-node/package.json | 2 +- .../{keycodec.test.ts => codec.test.ts} | 17 +- packages/e2ee-node/src/__tests__/e2ee.test.ts | 6 +- .../e2ee-node/src/{keyCodec.ts => codec.ts} | 30 ++- packages/e2ee-node/src/index.ts | 4 +- packages/e2ee-node/tsconfig.json | 20 +- packages/e2ee-web/package.json | 2 +- .../{keycodec.test.ts => codec.test.ts} | 18 +- packages/e2ee-web/src/__tests__/e2ee.test.ts | 6 +- .../e2ee-web/src/{keyCodec.ts => codec.ts} | 21 +- packages/e2ee-web/src/index.ts | 6 +- packages/e2ee-web/tsconfig.json | 20 +- packages/e2ee/.oxlintrc.json | 15 ++ packages/e2ee/HERMES.md | 103 ++++++++ packages/e2ee/package.json | 6 +- .../{keycodec.test.ts => codec.test.ts} | 31 +-- packages/e2ee/src/__tests__/e2ee.test.ts | 17 +- packages/e2ee/src/codec.ts | 228 ++++++++++++++++++ packages/e2ee/src/index.ts | 31 +-- packages/e2ee/src/keyCodec.ts | 125 ---------- packages/e2ee/src/utils.ts | 3 + .../e2ee/src/{wordList.ts => word-list.ts} | 1 + packages/e2ee/tsconfig.json | 1 + yarn.lock | 202 +++++++++++++++- 28 files changed, 707 insertions(+), 232 deletions(-) delete mode 100644 packages/e2ee-node/.gitignore rename packages/e2ee-node/src/__tests__/{keycodec.test.ts => codec.test.ts} (74%) rename packages/e2ee-node/src/{keyCodec.ts => codec.ts} (54%) rename packages/e2ee-web/src/__tests__/{keycodec.test.ts => codec.test.ts} (72%) rename packages/e2ee-web/src/{keyCodec.ts => codec.ts} (74%) create mode 100644 packages/e2ee/.oxlintrc.json create mode 100644 packages/e2ee/HERMES.md rename packages/e2ee/src/__tests__/{keycodec.test.ts => codec.test.ts} (68%) create mode 100644 packages/e2ee/src/codec.ts delete mode 100644 packages/e2ee/src/keyCodec.ts create mode 100644 packages/e2ee/src/utils.ts rename packages/e2ee/src/{wordList.ts => word-list.ts} (99%) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 0737611ae759d..5fc48b016e5fb 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -3,7 +3,9 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { E2EEBase, type KeyPair } from '@rocket.chat/e2ee'; +import type { KeyPair } from '@rocket.chat/e2ee'; +import type { Optional } from '@rocket.chat/e2ee/dist/utils'; +import E2EE from '@rocket.chat/e2ee-node'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -68,15 +70,14 @@ class E2E extends Emitter { private state: E2EEState; - private e2ee: E2EEBase; + private e2ee: E2EE; constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = new E2EEBase( - crypto, + this.e2ee = new E2EE( { load: (keyName) => Promise.resolve(Accounts.storageLocation.getItem(keyName)), store: (keyName, value) => Promise.resolve(Accounts.storageLocation.setItem(keyName, value)), @@ -308,7 +309,7 @@ class E2E extends Emitter { } private async persistKeys( - { public_key, private_key }: Partial, + { public_key, private_key }: Optional, password: string, { force }: { force: boolean } = { force: false }, ): Promise { @@ -387,7 +388,7 @@ class E2E extends Emitter { try { this.setState(E2EEState.ENTER_PASSWORD); private_key = await this.decodePrivateKey(this.db_private_key as string); - } catch (error) { + } catch { this.started = false; failedToDecodeKey = true; this.openAlert({ @@ -524,7 +525,7 @@ class E2E extends Emitter { } async createRandomPassword(): Promise { - return this.e2ee.createRandomPassword(); + return this.e2ee.createRandomPassword(5); } async encodePrivateKey(privateKey: string, password: string): Promise { @@ -641,7 +642,7 @@ class E2E extends Emitter { this.setState(E2EEState.READY); } dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); - } catch (error) { + } catch { this.setState(E2EEState.ENTER_PASSWORD); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); @@ -662,7 +663,7 @@ class E2E extends Emitter { } const privKey = await decryptAES(vector, masterKey, cipherText); return toString(privKey); - } catch (error) { + } catch { this.setState(E2EEState.ENTER_PASSWORD); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 0e818e4c00de2..48f4964592451 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -114,6 +114,7 @@ "@types/i18next-sprintf-postprocessor": "^0.2.3", "@types/imap": "^0.8.42", "@types/jest": "~30.0.0", + "@types/js-yaml": "~4.0.9", "@types/jsdom": "^21.1.7", "@types/jsdom-global": "^3.0.7", "@types/jsrsasign": "^10.5.15", @@ -249,7 +250,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/e2ee": "workspace:^", + "@rocket.chat/e2ee-web": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", diff --git a/package.json b/package.json index 383a0b6b72baa..f54976225cf3b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build:services": "turbo run build --filter=rocketchat-services...", "build:ci": "turbo run build:ci", "testunit": "turbo run testunit", + "typecheck": "turbo run typecheck", "test-storybook": "turbo run test-storybook", "dev": "turbo run dev --env-mode=loose --parallel --filter=@rocket.chat/meteor...", "dsv": "turbo run dsv --env-mode=loose --filter=@rocket.chat/meteor...", diff --git a/packages/e2ee-node/.gitignore b/packages/e2ee-node/.gitignore deleted file mode 100644 index 8658e1ccd0ef6..0000000000000 --- a/packages/e2ee-node/.gitignore +++ /dev/null @@ -1 +0,0 @@ -tsconfig.build.tsbuildinfo \ No newline at end of file diff --git a/packages/e2ee-node/package.json b/packages/e2ee-node/package.json index 2a1b3e84098dd..3a5ece76ba1d6 100644 --- a/packages/e2ee-node/package.json +++ b/packages/e2ee-node/package.json @@ -23,7 +23,7 @@ "testunit": "vitest run" }, "dependencies": { - "@rocket.chat/e2ee": "workspace:^" + "@rocket.chat/e2ee": "workspace:*" }, "devDependencies": { "@types/node": "~22.16.5", diff --git a/packages/e2ee-node/src/__tests__/keycodec.test.ts b/packages/e2ee-node/src/__tests__/codec.test.ts similarity index 74% rename from packages/e2ee-node/src/__tests__/keycodec.test.ts rename to packages/e2ee-node/src/__tests__/codec.test.ts index f46582b4ef585..a3d1bc07b9953 100644 --- a/packages/e2ee-node/src/__tests__/keycodec.test.ts +++ b/packages/e2ee-node/src/__tests__/codec.test.ts @@ -1,12 +1,14 @@ import { test, expect } from 'vitest'; -import { NodeKeyCodec } from '../keyCodec.ts'; +import KeyCodec from '../codec.ts'; -const createKeyCodec = () => new NodeKeyCodec(); +const createKeyCodec = () => new KeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); + + const saltBuffer = codec.encodeSalt('salt-user-1'); + const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); expect(dec).toBe(privateJWK); @@ -15,9 +17,9 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { test('KeyCodec wrong password fails', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const salt1 = new TextEncoder().encode('salt1'); - const masterKey = await codec.deriveMasterKey('correct', salt1); - const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); + const salt1 = codec.encodeSalt('salt1'); + const masterKey = await codec.deriveMasterKey(salt1, 'correct'); + const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); }); @@ -26,7 +28,8 @@ test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); // const privStr = JSON.stringify(privateJWK); - const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); + const salt = codec.encodeSalt('salt'); + const masterKey = await codec.deriveMasterKey(salt, 'pw'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); // Tamper with ctB64 const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index 17268659866df..0017da5ee9dea 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -1,6 +1,6 @@ import { test, assert } from 'vitest'; -import { NodeE2EE } from '../index.ts'; +import E2EE from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; class MockedKeyService implements KeyService { @@ -17,8 +17,8 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. const mockedKeyService = new MockedKeyService(); - const e2ee = NodeE2EE.withMemoryStorage(mockedKeyService); - const pwd = await e2ee.createRandomPassword(); + const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const pwd = await e2ee.createRandomPassword(5); assert.equal(pwd.split(' ').length, 5); assert.equal(await e2ee.getRandomPassword(), pwd); }); diff --git a/packages/e2ee-node/src/keyCodec.ts b/packages/e2ee-node/src/codec.ts similarity index 54% rename from packages/e2ee-node/src/keyCodec.ts rename to packages/e2ee-node/src/codec.ts index 8efddca781763..49a28d7a6f45b 100644 --- a/packages/e2ee-node/src/keyCodec.ts +++ b/packages/e2ee-node/src/codec.ts @@ -1,6 +1,6 @@ import { BaseKeyCodec } from '@rocket.chat/e2ee'; -export class NodeKeyCodec extends BaseKeyCodec { +export default class NodeKeyCodec extends BaseKeyCodec { constructor() { super({ exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), @@ -14,10 +14,34 @@ export class NodeKeyCodec extends BaseKeyCodec { true, ['encrypt', 'decrypt'], ), - decodeBase64: (input) => new Uint8Array(Buffer.from(input, 'base64')), - encodeBase64: (input) => Buffer.from(input).toString('base64'), + decodeBase64: (input) => Buffer.from(input, 'base64'), + encodeBase64: (bytes) => Buffer.from(bytes).toString('base64'), decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + encodeUtf8: (input) => { + const encoder = new TextEncoder(); + const dest = new Uint8Array(input.length); + encoder.encodeInto(input, dest); + return dest; + }, + decodeUtf8: (input) => new TextDecoder().decode(input), + deriveKeyWithPbkdf2: (salt, baseKey) => { + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + baseKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); + }, }); } } diff --git a/packages/e2ee-node/src/index.ts b/packages/e2ee-node/src/index.ts index 1ebd7ffa18fe0..b0995b988b7c4 100644 --- a/packages/e2ee-node/src/index.ts +++ b/packages/e2ee-node/src/index.ts @@ -1,5 +1,5 @@ import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; -import { NodeKeyCodec } from './keyCodec.ts'; +import NodeKeyCodec from './codec.ts'; class MemoryStorage implements KeyStorage { private map = new Map(); @@ -17,7 +17,7 @@ class MemoryStorage implements KeyStorage { } } -export class NodeE2EE extends BaseE2EE { +export default class NodeE2EE extends BaseE2EE { constructor(keyStorage: KeyStorage, keyService: KeyService) { super(new NodeKeyCodec(), keyStorage, keyService); } diff --git a/packages/e2ee-node/tsconfig.json b/packages/e2ee-node/tsconfig.json index 6e35b8ea9528b..bb6712aa3b6df 100644 --- a/packages/e2ee-node/tsconfig.json +++ b/packages/e2ee-node/tsconfig.json @@ -1,19 +1,21 @@ { "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "declarationMap": true, "composite": false, "module": "node20", - "target": "ES2024", + "lib": ["ESNext"], "types": ["node"], - /** Emit */ - "noEmit": true, + /** Modules */ + "rootDir": "src", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, + + /** Emit */ + "noEmit": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true, "verbatimModuleSyntax": true, "moduleDetection": "force", @@ -34,13 +36,11 @@ "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, - "isolatedModules": true, "isolatedDeclarations": true, - "allowJs": false, "esModuleInterop": false, - "skipLibCheck": false + "skipLibCheck": true }, "include": ["src"], "exclude": [] diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json index 59edb7bc4f982..61d1f880bd50e 100644 --- a/packages/e2ee-web/package.json +++ b/packages/e2ee-web/package.json @@ -23,7 +23,7 @@ "testunit": "vitest run" }, "dependencies": { - "@rocket.chat/e2ee": "workspace:^" + "@rocket.chat/e2ee": "workspace:*" }, "devDependencies": { "@vitest/browser": "4.0.0-beta.8", diff --git a/packages/e2ee-web/src/__tests__/keycodec.test.ts b/packages/e2ee-web/src/__tests__/codec.test.ts similarity index 72% rename from packages/e2ee-web/src/__tests__/keycodec.test.ts rename to packages/e2ee-web/src/__tests__/codec.test.ts index fca43b9be356c..5d42d296f2bec 100644 --- a/packages/e2ee-web/src/__tests__/keycodec.test.ts +++ b/packages/e2ee-web/src/__tests__/codec.test.ts @@ -1,12 +1,14 @@ import { test, expect } from 'vitest'; -import { WebKeyCodec } from '../keyCodec.ts'; +import KeyCodec from '../codec.ts'; -const createKeyCodec = () => new WebKeyCodec(); +const createKeyCodec = () => new KeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); + + const saltBuffer = codec.encodeSalt('salt-user-1'); + const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); expect(dec).toBe(privateJWK); @@ -15,9 +17,9 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { test('KeyCodec wrong password fails', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const salt1 = new TextEncoder().encode('salt1'); - const masterKey = await codec.deriveMasterKey('correct', salt1); - const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); + const salt1 = codec.encodeSalt('salt1'); + const masterKey = await codec.deriveMasterKey(salt1, 'correct'); + const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); }); @@ -25,8 +27,8 @@ test('KeyCodec wrong password fails', async () => { test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - // const privStr = JSON.stringify(privateJWK); - const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); + const salt = codec.encodeSalt('salt'); + const masterKey = await codec.deriveMasterKey(salt, 'pw'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); // Tamper with ctB64 const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts index dbd67f3ea4e8b..0017da5ee9dea 100644 --- a/packages/e2ee-web/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-web/src/__tests__/e2ee.test.ts @@ -1,6 +1,6 @@ import { test, assert } from 'vitest'; -import { WebE2EE } from '../index.ts'; +import E2EE from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; class MockedKeyService implements KeyService { @@ -17,8 +17,8 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. const mockedKeyService = new MockedKeyService(); - const e2ee = WebE2EE.withMemoryStorage(mockedKeyService); - const pwd = await e2ee.createRandomPassword(); + const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const pwd = await e2ee.createRandomPassword(5); assert.equal(pwd.split(' ').length, 5); assert.equal(await e2ee.getRandomPassword(), pwd); }); diff --git a/packages/e2ee-web/src/keyCodec.ts b/packages/e2ee-web/src/codec.ts similarity index 74% rename from packages/e2ee-web/src/keyCodec.ts rename to packages/e2ee-web/src/codec.ts index 86bc1de5056ab..896d333a9beaa 100644 --- a/packages/e2ee-web/src/keyCodec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -1,6 +1,6 @@ import { BaseKeyCodec } from '@rocket.chat/e2ee'; -export class WebKeyCodec extends BaseKeyCodec { +export default class WebKeyCodec extends BaseKeyCodec { constructor() { super({ exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), @@ -33,6 +33,25 @@ export class WebKeyCodec extends BaseKeyCodec { }, decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + encodeUtf8: (input) => new TextEncoder().encode(input), + decodeUtf8: (input) => new TextDecoder().decode(input), + deriveKeyWithPbkdf2: (salt, baseKey) => { + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + baseKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); + }, }); } } diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts index 5af18c1b93e68..fd4c7b1e4dc9d 100644 --- a/packages/e2ee-web/src/index.ts +++ b/packages/e2ee-web/src/index.ts @@ -1,5 +1,5 @@ import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; -import { WebKeyCodec } from './keyCodec.ts'; +import KeyCodec from './codec.ts'; class MemoryStorage implements KeyStorage { private map = new Map(); @@ -17,9 +17,9 @@ class MemoryStorage implements KeyStorage { } } -export class WebE2EE extends BaseE2EE { +export default class WebE2EE extends BaseE2EE { constructor(keyStorage: KeyStorage, keyService: KeyService) { - super(new WebKeyCodec(), keyStorage, keyService); + super(new KeyCodec(), keyStorage, keyService); } static withMemoryStorage(keyService: KeyService): WebE2EE { diff --git a/packages/e2ee-web/tsconfig.json b/packages/e2ee-web/tsconfig.json index d6452eb16762b..40f8682079d8a 100644 --- a/packages/e2ee-web/tsconfig.json +++ b/packages/e2ee-web/tsconfig.json @@ -1,19 +1,21 @@ { "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "declaration": true, - "declarationMap": true, "composite": false, - "module": "node20", + "module": "preserve", "target": "ES2024", + "lib": ["ESNext", "DOM"], "types": [], - "lib": ["ES2024","DOM"], - /** Emit */ - "noEmit": true, + /** Modules */ + "rootDir": "src", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, + + /** Emit */ + "noEmit": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true, "verbatimModuleSyntax": true, "moduleDetection": "force", @@ -34,10 +36,8 @@ "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, - "isolatedModules": true, "isolatedDeclarations": true, - "allowJs": false, "esModuleInterop": false, "skipLibCheck": false diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json new file mode 100644 index 0000000000000..627b1fd9bb89b --- /dev/null +++ b/packages/e2ee/.oxlintrc.json @@ -0,0 +1,15 @@ +{ + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error", + "restriction": "error", + "style": "error", + "nursery": "error", + "pedantic": "error" + }, + "rules": { + "oxc/no-map-spread": "error", + "oxc/no-async-await": "allow" + } +} \ No newline at end of file diff --git a/packages/e2ee/HERMES.md b/packages/e2ee/HERMES.md new file mode 100644 index 0000000000000..6e16c7a474ed5 --- /dev/null +++ b/packages/e2ee/HERMES.md @@ -0,0 +1,103 @@ +NaN +Infinity +undefined +parseInt +parseFloat +Object +Error +AggregateError +EvalError +RangeError +ReferenceError +SyntaxError +TypeError +URIError +TimeoutError +QuitError +String +BigInt +Function +Number +Boolean +Date +RegExp +Array +ArrayBuffer +DataView +Int8Array +Int16Array +Int32Array +Uint8Array +Uint8ClampedArray +Uint16Array +Uint32Array +Float32Array +Float64Array +BigInt64Array +BigUint64Array +Set +Map +WeakMap +WeakSet +Symbol +TextEncoder +Proxy +Math +JSON +Reflect +HermesInternal + concat + hasPromise + hasES6Class + enqueueJob + setPromiseRejectionTrackingHook + enablePromiseRejectionTracker + useEngineQueue + getEpilogues + getRuntimeProperties + ttiReached + ttrcReached + getFunctionLocation + getInstrumentedStats +DebuggerInternal + isDebuggerAttached + shouldPauseOnThrow +print +eval +isNaN +isFinite +escape +unescape +atob +btoa +decodeURI +decodeURIComponent +encodeURI +encodeURIComponent +globalThis +gc +Promise + length + name + caller + arguments + prototype + constructor + then + catch + finally + _l + _m + _n + resolve + all + allSettled + reject + race + any +quit +createHeapSnapshot +loadSegment +setTimeout +clearTimeout +setImmediate \ No newline at end of file diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 3b3a853072e58..9947ec35a5990 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -19,10 +19,14 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", - "lint": "eslint 'src/**/*.ts'", + "lint": "oxlint", "testunit": "node --experimental-strip-types --test" }, "devDependencies": { + "@noble/ciphers": "~1.3.0", + "@noble/curves": "~1.9.7", + "@noble/hashes": "~1.8.0", + "oxlint": "~1.11.2", "typescript": "~5.9.2" }, "license": "MIT", diff --git a/packages/e2ee/src/__tests__/keycodec.test.ts b/packages/e2ee/src/__tests__/codec.test.ts similarity index 68% rename from packages/e2ee/src/__tests__/keycodec.test.ts rename to packages/e2ee/src/__tests__/codec.test.ts index ef6d1b9b10251..798068b8eac6c 100644 --- a/packages/e2ee/src/__tests__/keycodec.test.ts +++ b/packages/e2ee/src/__tests__/codec.test.ts @@ -1,25 +1,17 @@ -// import { test } from 'node:test'; -// import * as assert from 'node:assert/strict'; -// import { webcrypto } from 'node:crypto'; -// import { KeyCodec, type CryptoProvider } from '../keyCodec.ts'; +// import { BaseKeyCodec, type CryptoProvider } from '../key-codec.ts'; -// const deps: CryptoProvider = { -// exportJsonWebKey: (key: CryptoKey): Promise => { -// return webcrypto.subtle.exportKey('jwk', key); -// }, -// getRandomValues: (array) => crypto.getRandomValues(array), -// importRawKey: (raw) => webcrypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), -// generateKeyPair: () => -// webcrypto.subtle.generateKey( -// { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, -// true, -// ['encrypt', 'decrypt'], -// ), -// decodeBase64: (input: string) => webcrypto.subtle.decodeBase64(input), -// }; +// class KeyCodec extends BaseKeyCodec { +// constructor() { +// super({ +// decodeBase64: (input) => { +// const binaryString = atob(data) +// } +// }); +// } +// } // test('KeyCodec roundtrip (v1 structured encoding)', async () => { -// const codec = new KeyCodec(deps); +// const codec = new KeyCodec(testCrypto); // const { privateJWK } = await codec.generateRSAKeyPair(); // const privStr = JSON.stringify(privateJWK); // const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); @@ -58,3 +50,4 @@ // const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); // assert.equal(dec, privStr); // }); +export {}; diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 12f026abfe02c..fda48dc45e7b1 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,7 +1,13 @@ -// import { test } from 'node:test'; -// import * as assert from 'node:assert/strict'; -// import { E2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; -// import { webcrypto } from 'node:crypto'; +// import { BaseE2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; +// import {} + +// class TestE2EE extends BaseE2EE { +// constructor() { +// super({ + +// }) +// } +// } // class MemoryStorage implements KeyStorage { // private map = new Map(); @@ -19,7 +25,7 @@ // } // } -// class DeterministicCrypto implements webcrypto.Crypto { +// class DeterministicCrypto { // private seq: number[]; // private idx = 0; // constructor(seq: number[], subtle: webcrypto.SubtleCrypto, CryptoKey: webcrypto.CryptoKeyConstructor) { @@ -73,3 +79,4 @@ // assert.equal(pwd.split(' ').length, 5); // assert.equal(await e2ee.getRandomPassword(), pwd); // }); +export {}; diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts new file mode 100644 index 0000000000000..8e61521cdf7ea --- /dev/null +++ b/packages/e2ee/src/codec.ts @@ -0,0 +1,228 @@ +export interface CryptoProvider { + /** + * @example + * (key) => await crypto.subtle.exportKey('jwk', key); + */ + exportJsonWebKey(key: CryptoKey): Promise; + /** + * @example + * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + */ + importRawKey(raw: Uint8Array): Promise; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint8Array(array: Uint8Array): Uint8Array; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint16Array(array: Uint16Array): Uint16Array; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint32Array(array: Uint32Array): Uint32Array; + /** + * @example + * crypto.subtle.generateKey( + * { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + * true, + * ['encrypt', 'decrypt'], + * ) + */ + generateKeyPair(): Promise; + /** + * @example + * (input) => { + * const binaryString = atob(input); + * const len = binaryString.length; + * const bytes = new Uint8Array(len); + * for (let i = 0; i < len; i++) { + * bytes[i] = binaryString.charCodeAt(i); + * } + * return bytes; + * } + */ + decodeBase64(input: string): Uint8Array; + /** + * @example + * (input) => { + * const bytes = new Uint8Array(input); + * let binaryString = ''; + * for (let i = 0; i < bytes.byteLength; i++) { + * binaryString += String.fromCharCode(bytes[i]!); + * } + * return btoa(binaryString); + * } + */ + encodeBase64(input: ArrayBuffer): string; + /** + * @example + * (input) => new TextEncoder().encode(input) + */ + encodeUtf8(input: string): Uint8Array; + /** + * @example + * (input) => TextDecoder().decode(input) + */ + decodeUtf8(input: ArrayBuffer): string; + /** + * @example + * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + */ + decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; + /** + * @example + * (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data) + */ + encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; + /** + * @example + * (key, iv, data) => crypto.subtle.deriveKey( + * { + * name: 'PBKDF2', salt, + * iterations: ITERATIONS, + * hash: 'SHA-256', + * }, + * baseKey, + * { + * name: 'AES-CBC', + * length: 256, + * }, + * false, + * ['encrypt', 'decrypt'] + * ) + */ + deriveKeyWithPbkdf2(salt: ArrayBuffer, baseKey: CryptoKey): Promise; +} + +export interface JsonWebKeyPair { + privateJWK: JsonWebKey; + publicJWK: JsonWebKey; +} + +export interface EncodedPrivateKeyV1 { + version: '1'; + mode: 'AES-CBC'; + kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number }; + ivB64: string; + /** ciphertext */ + ctB64: string; +} + +export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union + +const ITERATIONS = 1000; // mirrors legacy implementation + +export abstract class BaseKeyCodec { + #crypto: CryptoProvider; + + constructor(crypto: CryptoProvider) { + this.#crypto = crypto; + } + + async generateRSAKeyPair(): Promise { + const { generateKeyPair, exportJsonWebKey } = this.#crypto; + + const keyPair = await generateKeyPair(); + const publicJWK = await exportJsonWebKey(keyPair.publicKey); + const privateJWK = await exportJsonWebKey(keyPair.privateKey); + return { privateJWK, publicJWK }; + } + + async encodePrivateKey(privateKeyJwk: JsonWebKey, masterKey: CryptoKey): Promise { + const { getRandomUint8Array, encodeBase64, encryptAesCbc, encodeUtf8 } = this.#crypto; + const IV_LENGTH = 16; + const iv = getRandomUint8Array(new Uint8Array(IV_LENGTH)); + const data = encodeUtf8(JSON.stringify(privateKeyJwk)); + const ct = await encryptAesCbc(masterKey, iv, data); + + return { + ctB64: encodeBase64(ct), + ivB64: encodeBase64(iv.buffer), + kdf: { hash: 'SHA-256', iterations: ITERATIONS, name: 'PBKDF2' }, + mode: 'AES-CBC', + version: '1', + }; + } + + async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { + if (encoded.version !== '1') { + throw new Error(`Unsupported encoded private key version: ${encoded.version}`); + } + if (encoded.mode !== 'AES-CBC') { + throw new Error(`Unsupported cipher mode: ${encoded.mode}`); + } + const iv = this.#crypto.decodeBase64(encoded.ivB64); + const ct = this.#crypto.decodeBase64(encoded.ctB64); + try { + const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); + return JSON.parse(this.#crypto.decodeUtf8(plain)); + } catch { + throw new Error('Failed to decrypt private key'); + } + } + + encodeSalt(token: string): Uint8Array { + return this.#crypto.encodeUtf8(token); + } + + decodeSalt(salt: Uint8Array): string { + return this.#crypto.decodeUtf8(salt.buffer); + } + + async deriveMasterKey(salt: Uint8Array, password: string): Promise { + if (!password) { + throw new Error('Password is required'); + } + const { encodeUtf8, deriveKeyWithPbkdf2, importRawKey } = this.#crypto; + const baseKey = await importRawKey(encodeUtf8(password)); + return deriveKeyWithPbkdf2(salt.buffer, baseKey); + } + + // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. + async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { + const { getRandomUint8Array, encodeUtf8, encryptAesCbc } = this.#crypto; + const IV_LENGTH = 16; + const salt = encodeUtf8(saltStr); + const masterKey = await this.deriveMasterKey(salt, password); + const iv = getRandomUint8Array(new Uint8Array(IV_LENGTH)); + const data = encodeUtf8(privateKeyJwkString); + const ct = await encryptAesCbc(masterKey, iv, data); + const ctBytes = new Uint8Array(ct); + const out = new Uint8Array(iv.length + ctBytes.length); + out.set(iv); + out.set(ctBytes, iv.length); + return out; + } + + async legacyDecrypt(bytes: Uint8Array, password: string, saltStr: string): Promise { + const LEGACY_IV_START = 0; + const LEGACY_IV_LENGTH = 16; + if (bytes.length <= LEGACY_IV_LENGTH) { + throw new TypeError('Invalid legacy encrypted blob'); + } + + const { encodeUtf8, decodeUtf8 } = this.#crypto; + + const iv = bytes.slice(LEGACY_IV_START, LEGACY_IV_LENGTH); + const ct = bytes.slice(LEGACY_IV_LENGTH); + const salt = encodeUtf8(saltStr); + const masterKey = await this.deriveMasterKey(salt, password); + try { + const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); + return decodeUtf8(plain); + } catch { + throw new Error('Failed to decrypt legacy private key'); + } + } + + async generateMnemonicPhrase(length: number): Promise { + const { v1 } = await import('./word-list.ts'); + const randomBuffer = new Uint32Array(length); + this.#crypto.getRandomUint32Array(randomBuffer); + return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); + } +} diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 1a4b4dbd1c6a6..a30c865896dcf 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,6 +1,7 @@ -import type { BaseKeyCodec } from './keyCodec.ts'; +import type { BaseKeyCodec } from './codec.ts'; +import type { Optional } from './utils.ts'; -export { BaseKeyCodec } from './keyCodec.ts'; +export { BaseKeyCodec } from './codec.ts'; export interface KeyStorage { load(keyName: string): Promise; @@ -14,10 +15,11 @@ export interface KeyService { export type PrivateKey = string; export type PublicKey = string; -export type KeyPair = { + +export interface KeyPair { public_key: PublicKey; private_key: PrivateKey; -}; +} export abstract class BaseE2EE { #service: KeyService; @@ -30,12 +32,12 @@ export abstract class BaseE2EE { this.#crypto = crypto; } - async getKeysFromLocalStorage(): Promise> { - const public_key = (await this.#storage.load('public_key')) ?? undefined; - const private_key = (await this.#storage.load('private_key')) ?? undefined; + async getKeysFromLocalStorage(): Promise> { + const public_key = await this.#storage.load('public_key'); + const private_key = await this.#storage.load('private_key'); return { - ...(public_key ? { public_key } : {}), - ...(private_key ? { private_key } : {}), + private_key, + public_key, }; } @@ -53,16 +55,15 @@ export abstract class BaseE2EE { await this.#storage.store('e2e.random_password', randomPassword); } - async getRandomPassword(): Promise { + getRandomPassword(): Promise { return this.#storage.load('e2e.random_password'); } - async removeRandomPassword(): Promise { - await this.#storage.remove('e2e.random_password'); + removeRandomPassword(): Promise { + return this.#storage.remove('e2e.random_password'); } - - async createRandomPassword(): Promise { - const randomPassword = await this.#crypto.generateMnemonicPhrase(5); + async createRandomPassword(length: number): Promise { + const randomPassword = await this.#crypto.generateMnemonicPhrase(length); this.storeRandomPassword(randomPassword); return randomPassword; } diff --git a/packages/e2ee/src/keyCodec.ts b/packages/e2ee/src/keyCodec.ts deleted file mode 100644 index f2db12bc0cea6..0000000000000 --- a/packages/e2ee/src/keyCodec.ts +++ /dev/null @@ -1,125 +0,0 @@ -export interface CryptoProvider { - exportJsonWebKey(key: CryptoKey): Promise; - importRawKey(raw: Uint8Array): Promise; - getRandomUint8Array(array: Uint8Array): Uint8Array; - getRandomUint16Array(array: Uint16Array): Uint16Array; - getRandomUint32Array(array: Uint32Array): Uint32Array; - generateKeyPair(): Promise; - decodeBase64(input: string): Uint8Array; - encodeBase64(input: ArrayBuffer): string; - decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; - encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; -} - -export interface EncodedPrivateKeyV1 { - v: 1; - mode: 'AES-CBC'; - kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number }; - ivB64: string; - /** ciphertext */ - ctB64: string; -} - -export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union - -const ITERATIONS = 1000; // mirrors legacy implementation - -export abstract class BaseKeyCodec { - #crypto: CryptoProvider; - - constructor(deps: CryptoProvider) { - this.#crypto = deps; - } - - async generateRSAKeyPair(): Promise<{ publicJWK: JsonWebKey; privateJWK: JsonWebKey }> { - const { generateKeyPair, exportJsonWebKey } = this.#crypto; - - const keyPair = await generateKeyPair(); - const publicJWK = await exportJsonWebKey(keyPair.publicKey); - const privateJWK = await exportJsonWebKey(keyPair.privateKey); - return { publicJWK, privateJWK }; - } - - async encodePrivateKey(privateKeyJwk: JsonWebKey, masterKey: CryptoKey): Promise { - const { getRandomUint8Array, encodeBase64, encryptAesCbc } = this.#crypto; - - const iv = getRandomUint8Array(new Uint8Array(16)); - const data = new TextEncoder().encode(JSON.stringify(privateKeyJwk)); - const ct = await encryptAesCbc(masterKey, iv, data); - - return { - v: 1, - mode: 'AES-CBC', - kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations: ITERATIONS }, - ivB64: encodeBase64(iv.buffer), - ctB64: encodeBase64(ct), - }; - } - - async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { - if (encoded.v !== 1) throw new Error(`Unsupported encoded private key version: ${encoded.v}`); - if (encoded.mode !== 'AES-CBC') throw new Error(`Unsupported cipher mode: ${encoded.mode}`); - const iv = this.#crypto.decodeBase64(encoded.ivB64); - const ct = this.#crypto.decodeBase64(encoded.ctB64); - try { - const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return JSON.parse(new TextDecoder().decode(plain)); - } catch (e) { - throw new Error('Failed to decrypt private key'); - } - } - - async deriveMasterKey(password: string, salt: Uint8Array): Promise { - if (!password) throw new Error('Password is required'); - const enc = new TextEncoder(); - const baseKey = await this.#crypto.importRawKey(enc.encode(password)); - return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, - baseKey, - { name: 'AES-CBC', length: 256 }, - false, - ['encrypt', 'decrypt'], - ); - } - - // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. - async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { - const { getRandomUint8Array } = this.#crypto; - const salt = new TextEncoder().encode(saltStr); - const masterKey = await this.deriveMasterKey(password, salt); - const iv = getRandomUint8Array(new Uint8Array(16)); - const data = new TextEncoder().encode(privateKeyJwkString); - const ct = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, masterKey, data); - const ctBytes = new Uint8Array(ct); - const out = new Uint8Array(iv.length + ctBytes.length); - out.set(iv, 0); - out.set(ctBytes, iv.length); - return out; - } - - async legacyDecrypt(joined: ArrayBuffer | Uint8Array, password: string, saltStr: string): Promise { - const bytes = joined instanceof Uint8Array ? joined : new Uint8Array(joined); - if (bytes.length < 17) throw new Error('Invalid legacy encrypted blob'); - const iv = bytes.slice(0, 16); - const ct = bytes.slice(16); - const salt = new TextEncoder().encode(saltStr); - const masterKey = await this.deriveMasterKey(password, salt); - try { - const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return new TextDecoder().decode(plain); - } catch (e) { - throw new Error('Failed to decrypt legacy private key'); - } - } - - async generateMnemonicPhrase(n: number, sep = ' '): Promise { - const wordList = await import('./wordList.ts'); - const result = new Array(n); - const randomBuffer = new Uint32Array(n); - this.#crypto.getRandomUint32Array(randomBuffer); - for (let i = 0; i < n; i++) { - result[i] = wordList.v1[randomBuffer[i]! % wordList.v1.length]!; - } - return result.join(sep); - } -} diff --git a/packages/e2ee/src/utils.ts b/packages/e2ee/src/utils.ts new file mode 100644 index 0000000000000..afc72c90ec7cc --- /dev/null +++ b/packages/e2ee/src/utils.ts @@ -0,0 +1,3 @@ +export type Optional = { + [Key in keyof Obj]: Obj[Key] | null; +}; diff --git a/packages/e2ee/src/wordList.ts b/packages/e2ee/src/word-list.ts similarity index 99% rename from packages/e2ee/src/wordList.ts rename to packages/e2ee/src/word-list.ts index a6b1e480cc5f7..40cb3247f90f6 100644 --- a/packages/e2ee/src/wordList.ts +++ b/packages/e2ee/src/word-list.ts @@ -1,3 +1,4 @@ +// oxlint-disable max-lines export const v1: string[] = [ 'acrobat', 'africa', diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json index 5f77ebf8a676f..e4957aecc24b7 100644 --- a/packages/e2ee/tsconfig.json +++ b/packages/e2ee/tsconfig.json @@ -3,6 +3,7 @@ "composite": false, "module": "es2022", "target": "ES2024", + "lib": ["ESNext", "WebWorker"], "types": [], "typeRoots": [], diff --git a/yarn.lock b/yarn.lock index 454c5278a8543..946182264a7d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4171,7 +4171,23 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^1.1.5": +"@noble/ciphers@npm:~1.3.0": + version: 1.3.0 + resolution: "@noble/ciphers@npm:1.3.0" + checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 + languageName: node + linkType: hard + +"@noble/curves@npm:~1.9.7": + version: 1.9.7 + resolution: "@noble/curves@npm:1.9.7" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.1.5, @noble/hashes@npm:~1.8.0": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e @@ -4758,6 +4774,104 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/darwin-arm64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.0.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-x64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.0.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-arm64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.0.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-x64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.0.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-arm64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.0.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-x64@npm:0.0.2": + version: 0.0.2 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.0.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/darwin-arm64@npm:1.11.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/darwin-x64@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/darwin-x64@npm:1.11.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/linux-arm64-gnu@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/linux-arm64-gnu@npm:1.11.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-arm64-musl@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/linux-arm64-musl@npm:1.11.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/linux-x64-gnu@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/linux-x64-gnu@npm:1.11.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/linux-x64-musl@npm:1.11.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/win32-arm64@npm:1.11.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/win32-x64@npm:1.11.2": + version: 1.11.2 + resolution: "@oxlint/win32-x64@npm:1.11.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@paralleldrive/cuid2@npm:^2.2.2": version: 2.2.2 resolution: "@paralleldrive/cuid2@npm:2.2.2" @@ -7261,7 +7375,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/e2ee-node@workspace:packages/e2ee-node" dependencies: - "@rocket.chat/e2ee": "workspace:^" + "@rocket.chat/e2ee": "workspace:*" "@types/node": "npm:~22.16.5" typescript: "npm:~5.9.2" vitest: "npm:4.0.0-beta.8" @@ -7272,7 +7386,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/e2ee-web@workspace:packages/e2ee-web" dependencies: - "@rocket.chat/e2ee": "workspace:^" + "@rocket.chat/e2ee": "workspace:*" "@vitest/browser": "npm:4.0.0-beta.8" playwright: "npm:^1.54.2" typescript: "npm:~5.9.2" @@ -7280,10 +7394,14 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": +"@rocket.chat/e2ee@workspace:*, @rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": version: 0.0.0-use.local resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: + "@noble/ciphers": "npm:~1.3.0" + "@noble/curves": "npm:~1.9.7" + "@noble/hashes": "npm:~1.8.0" + oxlint: "npm:~1.11.2" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8023,6 +8141,7 @@ __metadata: "@types/i18next-sprintf-postprocessor": "npm:^0.2.3" "@types/imap": "npm:^0.8.42" "@types/jest": "npm:~30.0.0" + "@types/js-yaml": "npm:~4.0.9" "@types/jsdom": "npm:^21.1.7" "@types/jsdom-global": "npm:^3.0.7" "@types/jsrsasign": "npm:^10.5.15" @@ -11854,6 +11973,13 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:~4.0.9": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6 + languageName: node + linkType: hard + "@types/jsdom-global@npm:^3.0.7": version: 3.0.7 resolution: "@types/jsdom-global@npm:3.0.7" @@ -28382,6 +28508,74 @@ __metadata: languageName: node linkType: hard +"oxlint-tsgolint@npm:>=0.0.1": + version: 0.0.2 + resolution: "oxlint-tsgolint@npm:0.0.2" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.0.2" + "@oxlint-tsgolint/darwin-x64": "npm:0.0.2" + "@oxlint-tsgolint/linux-arm64": "npm:0.0.2" + "@oxlint-tsgolint/linux-x64": "npm:0.0.2" + "@oxlint-tsgolint/win32-arm64": "npm:0.0.2" + "@oxlint-tsgolint/win32-x64": "npm:0.0.2" + dependenciesMeta: + "@oxlint-tsgolint/darwin-arm64": + optional: true + "@oxlint-tsgolint/darwin-x64": + optional: true + "@oxlint-tsgolint/linux-arm64": + optional: true + "@oxlint-tsgolint/linux-x64": + optional: true + "@oxlint-tsgolint/win32-arm64": + optional: true + "@oxlint-tsgolint/win32-x64": + optional: true + bin: + tsgolint: bin/tsgolint.js + checksum: 10/8d0b0b60fcbd480098ab666d695f584e058398d5cccaa88a9ffbc5431654558ab0848b5116386cc30e03211a163bbb586d289f7f41a061b77ad7fdd8004d58a5 + languageName: node + linkType: hard + +"oxlint@npm:~1.11.2": + version: 1.11.2 + resolution: "oxlint@npm:1.11.2" + dependencies: + "@oxlint/darwin-arm64": "npm:1.11.2" + "@oxlint/darwin-x64": "npm:1.11.2" + "@oxlint/linux-arm64-gnu": "npm:1.11.2" + "@oxlint/linux-arm64-musl": "npm:1.11.2" + "@oxlint/linux-x64-gnu": "npm:1.11.2" + "@oxlint/linux-x64-musl": "npm:1.11.2" + "@oxlint/win32-arm64": "npm:1.11.2" + "@oxlint/win32-x64": "npm:1.11.2" + oxlint-tsgolint: "npm:>=0.0.1" + dependenciesMeta: + "@oxlint/darwin-arm64": + optional: true + "@oxlint/darwin-x64": + optional: true + "@oxlint/linux-arm64-gnu": + optional: true + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": + optional: true + "@oxlint/win32-arm64": + optional: true + "@oxlint/win32-x64": + optional: true + oxlint-tsgolint: + optional: true + bin: + oxc_language_server: bin/oxc_language_server + oxlint: bin/oxlint + checksum: 10/2ae18b22c9fe3aa93ea7a7049116a1ccb7cdfe22602bf58297115f1ce5aa633f508fe7b3f493f4193dc6cc00e97ba77f6f06fd895b24fdc3e89fe79c7d66d36e + languageName: node + linkType: hard + "p-cancelable@npm:^0.3.0": version: 0.3.0 resolution: "p-cancelable@npm:0.3.0" From fa851989232e132ca10a6cf59663329256683d7a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 15 Aug 2025 17:09:17 -0300 Subject: [PATCH 010/251] integrate in apps/meteor --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 2 +- apps/meteor/package.json | 1 + yarn.lock | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 5fc48b016e5fb..2265c18cb8e09 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -5,7 +5,7 @@ import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUs import { isE2EEMessage } from '@rocket.chat/core-typings'; import type { KeyPair } from '@rocket.chat/e2ee'; import type { Optional } from '@rocket.chat/e2ee/dist/utils'; -import E2EE from '@rocket.chat/e2ee-node'; +import E2EE from '@rocket.chat/e2ee-web'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 48f4964592451..0993654d50517 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -250,6 +250,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", + "@rocket.chat/e2ee": "workspace:^", "@rocket.chat/e2ee-web": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 946182264a7d4..d5d5f23e7a2a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7382,7 +7382,7 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/e2ee-web@workspace:packages/e2ee-web": +"@rocket.chat/e2ee-web@workspace:^, @rocket.chat/e2ee-web@workspace:packages/e2ee-web": version: 0.0.0-use.local resolution: "@rocket.chat/e2ee-web@workspace:packages/e2ee-web" dependencies: @@ -8042,6 +8042,7 @@ __metadata: "@rocket.chat/cron": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/e2ee": "workspace:^" + "@rocket.chat/e2ee-web": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" From 2ce03292dd41cec42aa2abf7edbf3f8e47470429 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 15 Aug 2025 18:21:16 -0300 Subject: [PATCH 011/251] fix tests --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 15 +++--------- packages/e2ee-node/package.json | 3 +++ .../e2ee-node/src/__tests__/codec.test.ts | 8 +++---- packages/e2ee-node/src/codec.ts | 7 ++++-- packages/e2ee-node/vitest.config.ts | 7 ++++++ packages/e2ee-web/src/__tests__/codec.test.ts | 7 +++--- packages/e2ee-web/src/codec.ts | 6 +++-- packages/e2ee/package.json | 3 +++ packages/e2ee/src/codec.ts | 23 +++++++++++++++---- packages/e2ee/src/index.ts | 12 ++++++---- 10 files changed, 59 insertions(+), 32 deletions(-) create mode 100644 packages/e2ee-node/vitest.config.ts diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 2265c18cb8e09..273ee2f2dfeff 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -25,8 +25,8 @@ import { generateRSAKey, exportJWKKey, importRSAKey, - importRawKey, - deriveKey, + // deriveKey, + // importRawKey } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -550,18 +550,9 @@ class E2E extends Emitter { alert('You should provide a password'); } - // First, create a PBKDF2 "key" containing the password - let baseKey; - try { - baseKey = await importRawKey(toArrayBuffer(password)); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error creating a key based on user password: ', error); - } - // Derive a key from the password try { - return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); + return await this.e2ee.codec.deriveMasterKey(toArrayBuffer(Meteor.userId()), password); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error deriving baseKey: ', error); diff --git a/packages/e2ee-node/package.json b/packages/e2ee-node/package.json index 3a5ece76ba1d6..e66d98d29d8ad 100644 --- a/packages/e2ee-node/package.json +++ b/packages/e2ee-node/package.json @@ -33,5 +33,8 @@ "license": "MIT", "publishConfig": { "access": "public" + }, + "volta": { + "extends": "../../package.json" } } diff --git a/packages/e2ee-node/src/__tests__/codec.test.ts b/packages/e2ee-node/src/__tests__/codec.test.ts index a3d1bc07b9953..d867ce910d8cd 100644 --- a/packages/e2ee-node/src/__tests__/codec.test.ts +++ b/packages/e2ee-node/src/__tests__/codec.test.ts @@ -6,12 +6,11 @@ const createKeyCodec = () => new KeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); const { privateJWK } = await codec.generateRSAKeyPair(); - const saltBuffer = codec.encodeSalt('salt-user-1'); const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); - expect(dec).toBe(privateJWK); + expect(dec).toStrictEqual(privateJWK); }); test('KeyCodec wrong password fails', async () => { @@ -41,6 +40,7 @@ test('KeyCodec legacy roundtrip', async () => { const { privateJWK } = await codec.generateRSAKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); - const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); - expect(dec).toBe(privateJWK); + const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); + const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as any) : decAny; + expect(dec).toStrictEqual(privateJWK); }); diff --git a/packages/e2ee-node/src/codec.ts b/packages/e2ee-node/src/codec.ts index 49a28d7a6f45b..08bf174013bf0 100644 --- a/packages/e2ee-node/src/codec.ts +++ b/packages/e2ee-node/src/codec.ts @@ -26,16 +26,19 @@ export default class NodeKeyCodec extends BaseKeyCodec { }, decodeUtf8: (input) => new TextDecoder().decode(input), deriveKeyWithPbkdf2: (salt, baseKey) => { + // The BaseKeyCodec expects an AES-CBC key with 256-bit length and ITERATIONS = 1000 for + // encode/decode operations. Previous implementation derived an AES-GCM key with 100000 iterations, + // which caused InvalidAccessError when using the key for AES-CBC encryption in tests. return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, - iterations: 100000, + iterations: 1000, hash: 'SHA-256', }, baseKey, { - name: 'AES-GCM', + name: 'AES-CBC', length: 256, }, false, diff --git a/packages/e2ee-node/vitest.config.ts b/packages/e2ee-node/vitest.config.ts new file mode 100644 index 0000000000000..1c73ca943465f --- /dev/null +++ b/packages/e2ee-node/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + fileParallelism: false, + }, +}); diff --git a/packages/e2ee-web/src/__tests__/codec.test.ts b/packages/e2ee-web/src/__tests__/codec.test.ts index 5d42d296f2bec..29eec246c50ad 100644 --- a/packages/e2ee-web/src/__tests__/codec.test.ts +++ b/packages/e2ee-web/src/__tests__/codec.test.ts @@ -11,7 +11,7 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); const dec = await codec.decodePrivateKey(enc, masterKey); - expect(dec).toBe(privateJWK); + expect(dec).toStrictEqual(privateJWK); }); test('KeyCodec wrong password fails', async () => { @@ -40,6 +40,7 @@ test('KeyCodec legacy roundtrip', async () => { const { privateJWK } = await codec.generateRSAKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); - const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); - expect(dec).toBe(privateJWK); + const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); + const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as JsonWebKey) : decAny; + expect(dec).toStrictEqual(privateJWK); }); diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts index 896d333a9beaa..5bef77bae30aa 100644 --- a/packages/e2ee-web/src/codec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -36,16 +36,18 @@ export default class WebKeyCodec extends BaseKeyCodec { encodeUtf8: (input) => new TextEncoder().encode(input), decodeUtf8: (input) => new TextDecoder().decode(input), deriveKeyWithPbkdf2: (salt, baseKey) => { + // Align with BaseKeyCodec expectations: derive an AES-CBC 256-bit key with 1000 iterations (legacy compatibility) + // Previous version derived AES-GCM with 100000 iterations, causing algorithm mismatch errors in tests. return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, - iterations: 100000, + iterations: 1000, hash: 'SHA-256', }, baseKey, { - name: 'AES-GCM', + name: 'AES-CBC', length: 256, }, false, diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 9947ec35a5990..8a778df685679 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -32,5 +32,8 @@ "license": "MIT", "publishConfig": { "access": "public" + }, + "volta": { + "extends": "../../package.json" } } diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 8e61521cdf7ea..fff32731a5839 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -119,6 +119,10 @@ const ITERATIONS = 1000; // mirrors legacy implementation export abstract class BaseKeyCodec { #crypto: CryptoProvider; + get crypto(): CryptoProvider { + return this.#crypto; + } + constructor(crypto: CryptoProvider) { this.#crypto = crypto; } @@ -159,7 +163,7 @@ export abstract class BaseKeyCodec { const ct = this.#crypto.decodeBase64(encoded.ctB64); try { const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return JSON.parse(this.#crypto.decodeUtf8(plain)); + return JSON.parse(this.#crypto.decodeUtf8(plain)) as JsonWebKey; } catch { throw new Error('Failed to decrypt private key'); } @@ -178,8 +182,17 @@ export abstract class BaseKeyCodec { throw new Error('Password is required'); } const { encodeUtf8, deriveKeyWithPbkdf2, importRawKey } = this.#crypto; - const baseKey = await importRawKey(encodeUtf8(password)); - return deriveKeyWithPbkdf2(salt.buffer, baseKey); + + try { + const baseKey = await importRawKey(encodeUtf8(password)); + return deriveKeyWithPbkdf2(salt.buffer, baseKey); + } catch (error) { + if (error instanceof Error) { + throw new TypeError(`Invalid password: ${error.message}`); + } else { + throw error; + } + } } // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. @@ -198,7 +211,7 @@ export abstract class BaseKeyCodec { return out; } - async legacyDecrypt(bytes: Uint8Array, password: string, saltStr: string): Promise { + async legacyDecrypt(bytes: Uint8Array, password: string, saltStr: string): Promise { const LEGACY_IV_START = 0; const LEGACY_IV_LENGTH = 16; if (bytes.length <= LEGACY_IV_LENGTH) { @@ -213,7 +226,7 @@ export abstract class BaseKeyCodec { const masterKey = await this.deriveMasterKey(salt, password); try { const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return decodeUtf8(plain); + return JSON.parse(decodeUtf8(plain)) as JsonWebKey; } catch { throw new Error('Failed to decrypt legacy private key'); } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index a30c865896dcf..5d12e547cfa9c 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -24,12 +24,16 @@ export interface KeyPair { export abstract class BaseE2EE { #service: KeyService; #storage: KeyStorage; - #crypto: BaseKeyCodec; + #codec: BaseKeyCodec; - constructor(crypto: BaseKeyCodec, storage: KeyStorage, service: KeyService) { + get codec(): BaseKeyCodec { + return this.#codec; + } + + constructor(codec: BaseKeyCodec, storage: KeyStorage, service: KeyService) { this.#storage = storage; this.#service = service; - this.#crypto = crypto; + this.#codec = codec; } async getKeysFromLocalStorage(): Promise> { @@ -63,7 +67,7 @@ export abstract class BaseE2EE { return this.#storage.remove('e2e.random_password'); } async createRandomPassword(length: number): Promise { - const randomPassword = await this.#crypto.generateMnemonicPhrase(length); + const randomPassword = await this.#codec.generateMnemonicPhrase(length); this.storeRandomPassword(randomPassword); return randomPassword; } From 442feb3693a7c85a3930727b2f8d7cde95b7ea96 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 15 Aug 2025 18:30:37 -0300 Subject: [PATCH 012/251] fix import key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 273ee2f2dfeff..21193126cce7b 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -25,8 +25,7 @@ import { generateRSAKey, exportJWKKey, importRSAKey, - // deriveKey, - // importRawKey + deriveKey, } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -550,9 +549,18 @@ class E2E extends Emitter { alert('You should provide a password'); } + // First, create a PBKDF2 "key" containing the password + let baseKey; + try { + baseKey = await this.e2ee.codec.crypto.importRawKey(toArrayBuffer(password)); + } catch (error) { + this.setState(E2EEState.ERROR); + return this.error('Error creating a key based on user password: ', error); + } + // Derive a key from the password try { - return await this.e2ee.codec.deriveMasterKey(toArrayBuffer(Meteor.userId()), password); + return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error deriving baseKey: ', error); From a8a2aeae5feb49bc8e782f8332eacc386ad14b50 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 16 Aug 2025 10:53:39 -0300 Subject: [PATCH 013/251] create base key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 13 +- apps/meteor/package.json | 2 +- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 328 ++++++++++--------- packages/e2ee-node/src/codec.ts | 6 + packages/e2ee-web/package.json | 2 +- packages/e2ee-web/src/codec.ts | 6 + packages/e2ee/src/codec.ts | 53 ++- packages/ui-voip/package.json | 2 +- 8 files changed, 237 insertions(+), 175 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 21193126cce7b..2019f3c88e65f 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -52,6 +52,8 @@ let failedToDecodeKey = false; const ROOM_KEY_EXCHANGE_SIZE = 10; const E2EEStateDependency = new Tracker.Dependency(); +// + class E2E extends Emitter { private started: boolean; @@ -552,15 +554,22 @@ class E2E extends Emitter { // First, create a PBKDF2 "key" containing the password let baseKey; try { - baseKey = await this.e2ee.codec.crypto.importRawKey(toArrayBuffer(password)); + baseKey = await this.e2ee.codec.createBaseKey(password); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error creating a key based on user password: ', error); } + const userId = Meteor.userId(); + + if (!userId) { + this.setState(E2EEState.ERROR); + return this.error('User not found'); + } + // Derive a key from the password try { - return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); + return await deriveKey(toArrayBuffer(userId), baseKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error deriving baseKey: ', error); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 0993654d50517..4fd55b7fe3a63 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -72,7 +72,7 @@ "@babel/preset-react": "~7.25.9", "@babel/register": "~7.25.9", "@faker-js/faker": "~8.0.2", - "@playwright/test": "^1.52.0", + "@playwright/test": "~1.54.2", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/livechat": "workspace:^", diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 3d94e212b875b..a3ce1480a5c57 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -319,7 +319,7 @@ test.describe('basic features', () => { test.describe('e2e-encryption', () => { let poHomeChannel: HomeChannel; - test.use({ storageState: Users.userE2EE.state }); + test.use({ storageState: Users.admin.state }); test.beforeAll(async ({ api }) => { await api.post('/settings/E2E_Enable', { value: true }); @@ -331,244 +331,248 @@ test.describe('e2e-encryption', () => { await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - await page.goto('/home'); - }); + test.describe('user', () => { + test.use({ storageState: Users.userE2EE.state }); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); - test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('hello world'); + await poHomeChannel.content.sendMessage('hello world'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await poHomeChannel.tabs.kebab.click({ force: true }); + await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnDisableE2E).toBeVisible(); - await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Disable encryption' }).click(); - await poHomeChannel.dismissToast(); - await page.waitForTimeout(1000); + await expect(poHomeChannel.tabs.btnDisableE2E).toBeVisible(); + await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Disable encryption' }).click(); + await poHomeChannel.dismissToast(); + await page.waitForTimeout(1000); - await poHomeChannel.content.sendMessage('hello world not encrypted'); + await poHomeChannel.content.sendMessage('hello world not encrypted'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await poHomeChannel.dismissToast(); - await page.waitForTimeout(1000); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await poHomeChannel.dismissToast(); + await page.waitForTimeout(1000); - await poHomeChannel.content.sendMessage('hello world encrypted again'); + await poHomeChannel.content.sendMessage('hello world encrypted again'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); - test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('This is the thread main message.'); + await poHomeChannel.content.sendMessage('This is the thread main message.'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await page.locator('[data-qa-type="message"]').last().hover(); + await page.locator('role=button[name="Reply in thread"]').click(); - await expect(page).toHaveURL(/.*thread/); + await expect(page).toHaveURL(/.*thread/); - await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); - await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); - await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel'); - await page.keyboard.press('Enter'); - await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is an encrypted thread message also sent in channel'); - await expect(poHomeChannel.content.lastThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.lastUserMessage).toContainText('This is an encrypted thread message also sent in channel'); - await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); - await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); + await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.lastThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + }); - test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('This is an encrypted message.'); + await poHomeChannel.content.sendMessage('This is an encrypted message.'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await page.locator('[data-qa-type="message"]').last().hover(); - await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); + await page.locator('[data-qa-type="message"]').last().hover(); + await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); - await poHomeChannel.content.openLastMessageMenu(); + await poHomeChannel.content.openLastMessageMenu(); - await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/); - await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); - }); + await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/); + await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + }); - test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.toastSuccess).toBeVisible(); + await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.dismissToast(); + await poHomeChannel.dismissToast(); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await page.waitForTimeout(1000); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('hello world'); + await poHomeChannel.content.sendMessage('hello world'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); - test('expect create a encrypted private channel and mention user', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a encrypted private channel and mention user', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('hello @user1'); + await poHomeChannel.content.sendMessage('hello @user1'); - await expect( - page.getByRole('button', { - name: 'user1', - }), - ).toBeVisible(); - }); + await expect( + page.getByRole('button', { + name: 'user1', + }), + ).toBeVisible(); + }); - test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + await poHomeChannel.content.sendMessage('Are you in the #general channel?'); - const channelMention = page.getByRole('button', { - name: 'general', - }); + const channelMention = page.getByRole('button', { + name: 'general', + }); - await expect(channelMention).toBeVisible(); + await expect(channelMention).toBeVisible(); - await channelMention.click(); + await channelMention.click(); - await expect(page).toHaveURL(`/channel/general`); - }); + await expect(page).toHaveURL(`/channel/general`); + }); - test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { - const channelName = faker.string.uuid(); + test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); + await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); - await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); - }); + await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); + }); - test('should encrypted field be available on edit room', async ({ page }) => { - const channelName = faker.string.uuid(); + test('should encrypted field be available on edit room', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.toastSuccess).toBeVisible(); + await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.dismissToast(); + await poHomeChannel.dismissToast(); - await poHomeChannel.tabs.btnRoomInfo.click(); - await poHomeChannel.tabs.room.btnEdit.click(); - await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); + await poHomeChannel.tabs.btnRoomInfo.click(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); - await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); - }); + await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); + }); - test('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { - // delete direct message if it exists - await api.post('/im.delete', { username: 'user2' }); - await page.reload(); + test.fixme('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { + // delete direct message if it exists + await api.post('/im.delete', { username: 'user2' }); + await page.reload(); - await poHomeChannel.sidenav.openNewByLabel('Direct message'); - await poHomeChannel.sidenav.inputDirectUsername.click(); - await page.keyboard.type('user2'); - await page.waitForTimeout(1000); - await page.keyboard.press('Enter'); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.openNewByLabel('Direct message'); + await poHomeChannel.sidenav.inputDirectUsername.click(); + await page.keyboard.type('user2'); + await page.waitForTimeout(1000); + await page.keyboard.press('Enter'); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); + await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await page.waitForTimeout(1000); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.dismissToast(); + await poHomeChannel.dismissToast(); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableOTR).toBeVisible(); - await poHomeChannel.tabs.btnEnableOTR.click({ force: true }); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableOTR).toBeVisible(); + await poHomeChannel.tabs.btnEnableOTR.click({ force: true }); - await expect(page.getByText('OTR not available')).toBeVisible(); + await expect(page.getByText('OTR not available')).toBeVisible(); + }); }); test.describe('File Encryption', async () => { + test.use({ storageState: Users.userE2EE.state }); test.afterAll(async ({ api }) => { await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' }); await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); @@ -711,7 +715,7 @@ test.describe('e2e-encryption', () => { }); }); - test('expect slash commands to be enabled in an e2ee room', async ({ page }) => { + test.fixme('expect slash commands to be enabled in an e2ee room', async ({ page }) => { test.skip(!IS_EE, 'Premium Only'); const channelName = faker.string.uuid(); @@ -737,7 +741,7 @@ test.describe('e2e-encryption', () => { await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); }); - test.describe('un-encrypted messages not allowed in e2ee rooms', () => { + test.describe.fixme('un-encrypted messages not allowed in e2ee rooms', () => { test.skip(!IS_EE, 'Premium Only'); let poHomeChannel: HomeChannel; @@ -899,7 +903,7 @@ test.describe('e2e-encryption', () => { test.use({ storageState: Users.admin.state }); -test.describe.serial('e2ee room setup', () => { +test.describe.fixme('e2ee room setup', () => { let poAccountProfile: AccountProfile; let poHomeChannel: HomeChannel; let e2eePassword: string; diff --git a/packages/e2ee-node/src/codec.ts b/packages/e2ee-node/src/codec.ts index 08bf174013bf0..44b3224dd3173 100644 --- a/packages/e2ee-node/src/codec.ts +++ b/packages/e2ee-node/src/codec.ts @@ -45,6 +45,12 @@ export default class NodeKeyCodec extends BaseKeyCodec { ['encrypt', 'decrypt'], ); }, + encodeBinary: (input) => { + const encoder = new TextEncoder(); + const dest = new Uint8Array(input.length); + encoder.encodeInto(input, dest); + return dest; + }, }); } } diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json index 61d1f880bd50e..db69eef6f8146 100644 --- a/packages/e2ee-web/package.json +++ b/packages/e2ee-web/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "@vitest/browser": "4.0.0-beta.8", - "playwright": "^1.54.2", + "playwright": "~1.54.2", "typescript": "~5.9.2", "vitest": "4.0.0-beta.8" }, diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts index 5bef77bae30aa..cb3b7748ae6cd 100644 --- a/packages/e2ee-web/src/codec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -31,6 +31,12 @@ export default class WebKeyCodec extends BaseKeyCodec { } return btoa(binaryString); }, + encodeBinary: (input) => { + const encoder = new TextEncoder(); + const dest = new Uint8Array(input.length); + encoder.encodeInto(input, dest); + return dest; + }, decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), encodeUtf8: (input) => new TextEncoder().encode(input), diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index fff32731a5839..7a596f96b1675 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -60,30 +60,57 @@ export interface CryptoProvider { encodeBase64(input: ArrayBuffer): string; /** * @example + * NodeJS / Web: + * ``` + * (text: string): Uint8Array => { + * const encoder = new TextEncoder(); + * const buffer = new Uint8Array(); + * encoder.encodeInto(text, buffer); + * return buffer; + * }; + * ``` + */ + encodeBinary(input: string): Uint8Array; + /** + * @example + * NodeJS / Web: + * ``` * (input) => new TextEncoder().encode(input) + * ``` */ encodeUtf8(input: string): Uint8Array; /** * @example + * NodeJS / Web: + * ``` * (input) => TextDecoder().decode(input) + * ``` */ decodeUtf8(input: ArrayBuffer): string; /** * @example - * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + * NodeJS / Web: + * ``` + * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data) + * ``` */ decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; /** * @example + * NodeJS / Web: + * ``` * (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data) + * ```` */ encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; /** * @example + * NodeJS / Web: * (key, iv, data) => crypto.subtle.deriveKey( * { - * name: 'PBKDF2', salt, - * iterations: ITERATIONS, + * name: 'PBKDF2', + * salt, + * iterations: 1000, * hash: 'SHA-256', * }, * baseKey, @@ -103,19 +130,21 @@ export interface JsonWebKeyPair { publicJWK: JsonWebKey; } +const V1_ITERATIONS = 1000; // mirrors legacy implementation export interface EncodedPrivateKeyV1 { version: '1'; mode: 'AES-CBC'; - kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number }; + kdf: { + name: 'PBKDF2'; + hash: 'SHA-256'; + iterations: typeof V1_ITERATIONS; + }; ivB64: string; /** ciphertext */ ctB64: string; } export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union - -const ITERATIONS = 1000; // mirrors legacy implementation - export abstract class BaseKeyCodec { #crypto: CryptoProvider; @@ -146,7 +175,7 @@ export abstract class BaseKeyCodec { return { ctB64: encodeBase64(ct), ivB64: encodeBase64(iv.buffer), - kdf: { hash: 'SHA-256', iterations: ITERATIONS, name: 'PBKDF2' }, + kdf: { hash: 'SHA-256', iterations: V1_ITERATIONS, name: 'PBKDF2' }, mode: 'AES-CBC', version: '1', }; @@ -177,6 +206,14 @@ export abstract class BaseKeyCodec { return this.#crypto.decodeUtf8(salt.buffer); } + createBaseKey(password: string): Promise { + if (!password) { + throw new Error('Password is required'); + } + const { encodeBinary, importRawKey } = this.#crypto; + return importRawKey(encodeBinary(password)); + } + async deriveMasterKey(salt: Uint8Array, password: string): Promise { if (!password) { throw new Error('Password is required'); diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index d41dce9466d6d..d29d095061ad6 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@babel/core": "~7.26.10", "@faker-js/faker": "~8.0.2", - "@playwright/test": "^1.52.0", + "@playwright/test": "~1.54.2", "@react-spectrum/test-utils": "~1.0.0-alpha.8", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", From 61f355104bcf02602cd67c0846b728ada56854d2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 16 Aug 2025 11:03:56 -0300 Subject: [PATCH 014/251] encode binary --- apps/meteor/app/e2e/client/helper.ts | 13 ++++++++----- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/e2e/client/helper.ts b/apps/meteor/app/e2e/client/helper.ts index cf4b395b4f471..fdb58476c5dc6 100644 --- a/apps/meteor/app/e2e/client/helper.ts +++ b/apps/meteor/app/e2e/client/helper.ts @@ -108,11 +108,14 @@ export async function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArr return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); } -export async function deriveKey(salt: any, baseKey: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - const iterations = 1000; - const hash = 'SHA-256'; - - return crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations, hash }, baseKey, { name: 'AES-CBC', length: 256 }, true, keyUsages); +export async function deriveKey(salt: Uint8Array, baseKey: CryptoKey) { + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 1000, hash: 'SHA-256' }, + baseKey, + { name: 'AES-CBC', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); } export async function readFileAsArrayBuffer(file: File) { diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 2019f3c88e65f..9ec9a464cd0c9 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -569,7 +569,7 @@ class E2E extends Emitter { // Derive a key from the password try { - return await deriveKey(toArrayBuffer(userId), baseKey); + return await deriveKey(this.e2ee.codec.crypto.encodeBinary(userId), baseKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error deriving baseKey: ', error); From 197191e3621817fb2f5dee4215f72771993c21da Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 16 Aug 2025 11:29:16 -0300 Subject: [PATCH 015/251] derive key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 4 +- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 549 +++++++++---------- packages/e2ee/src/codec.ts | 4 + yarn.lock | 36 +- 4 files changed, 281 insertions(+), 312 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 9ec9a464cd0c9..bcc3fffe1919b 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -25,7 +25,7 @@ import { generateRSAKey, exportJWKKey, importRSAKey, - deriveKey, + // deriveKey, } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -569,7 +569,7 @@ class E2E extends Emitter { // Derive a key from the password try { - return await deriveKey(this.e2ee.codec.crypto.encodeBinary(userId), baseKey); + return await this.e2ee.codec.deriveKey(userId, baseKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error deriving baseKey: ', error); diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index a3ce1480a5c57..179c11ce4a1d0 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -21,23 +21,22 @@ import { FileUploadModal } from './page-objects/fragments/file-upload-modal'; import { LoginPage } from './page-objects/login'; import { test, expect } from './utils/test'; -test.beforeAll(async () => { +test.use({ storageState: Users.admin.state }); + +test.beforeAll(async ({ api }) => { await injectInitialData(); + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); +}); + +test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: false }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); }); test.describe('initial setup', () => { test.use({ storageState: Users.admin.state }); - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: true }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); - }); - - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); - }); - test.beforeEach(async ({ api, page }) => { const loginPage = new LoginPage(page); @@ -132,18 +131,6 @@ test.describe('initial setup', () => { }); test.describe('basic features', () => { - test.use({ storageState: Users.admin.state }); - - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: true }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); - }); - - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); - }); - test.beforeEach(async ({ api, page }) => { const loginPage = new LoginPage(page); @@ -319,266 +306,344 @@ test.describe('basic features', () => { test.describe('e2e-encryption', () => { let poHomeChannel: HomeChannel; - test.use({ storageState: Users.admin.state }); - - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: true }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + test.use({ storageState: Users.userE2EE.state }); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); }); - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello world'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await poHomeChannel.tabs.kebab.click({ force: true }); + + await expect(poHomeChannel.tabs.btnDisableE2E).toBeVisible(); + await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Disable encryption' }).click(); + await poHomeChannel.dismissToast(); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world not encrypted'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await poHomeChannel.dismissToast(); + await page.waitForTimeout(1000); + + await poHomeChannel.content.sendMessage('hello world encrypted again'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); - test.describe('user', () => { - test.use({ storageState: Users.userE2EE.state }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - await page.goto('/home'); - }); + test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => { + const channelName = faker.string.uuid(); - test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await poHomeChannel.content.sendMessage('This is the thread main message.'); - await poHomeChannel.content.sendMessage('hello world'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await page.locator('[data-qa-type="message"]').last().hover(); + await page.locator('role=button[name="Reply in thread"]').click(); - await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(page).toHaveURL(/.*thread/); - await expect(poHomeChannel.tabs.btnDisableE2E).toBeVisible(); - await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Disable encryption' }).click(); - await poHomeChannel.dismissToast(); - await page.waitForTimeout(1000); + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - await poHomeChannel.content.sendMessage('hello world not encrypted'); + await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); + await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel'); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.lastThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage).toContainText('This is an encrypted thread message also sent in channel'); + await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); + await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + }); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world not encrypted'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await poHomeChannel.dismissToast(); - await page.waitForTimeout(1000); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await poHomeChannel.content.sendMessage('hello world encrypted again'); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world encrypted again'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - test('expect create a private encrypted channel and send a encrypted thread message', async ({ page }) => { - const channelName = faker.string.uuid(); + await poHomeChannel.content.sendMessage('This is an encrypted message.'); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(page).toHaveURL(`/group/${channelName}`); + await page.locator('[data-qa-type="message"]').last().hover(); + await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await poHomeChannel.content.openLastMessageMenu(); - await poHomeChannel.content.sendMessage('This is the thread main message.'); + await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/); + await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + }); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is the thread main message.'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { + const channelName = faker.string.uuid(); - await page.locator('[data-qa-type="message"]').last().hover(); - await page.locator('role=button[name="Reply in thread"]').click(); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(/.*thread/); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); - await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.content.toggleAlsoSendThreadToChannel(true); - await page.getByRole('dialog').locator('[name="msg"]').last().fill('This is an encrypted thread message also sent in channel'); - await page.keyboard.press('Enter'); - await expect(poHomeChannel.content.lastThreadMessageText).toContainText('This is an encrypted thread message also sent in channel'); - await expect(poHomeChannel.content.lastThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.lastUserMessage).toContainText('This is an encrypted thread message also sent in channel'); - await expect(poHomeChannel.content.mainThreadMessageText).toContainText('This is the thread main message.'); - await expect(poHomeChannel.content.mainThreadMessageText.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await poHomeChannel.dismissToast(); - test('expect create a private encrypted channel and check disabled message menu actions on an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await expect(page).toHaveURL(`/group/${channelName}`); + await poHomeChannel.content.sendMessage('hello world'); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); - await poHomeChannel.content.sendMessage('This is an encrypted message.'); + test('expect create a encrypted private channel and mention user', async ({ page }) => { + const channelName = faker.string.uuid(); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await page.locator('[data-qa-type="message"]').last().hover(); - await expect(page.locator('role=button[name="Forward message not available on encrypted content"]')).toBeDisabled(); + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('hello @user1'); - await poHomeChannel.content.openLastMessageMenu(); + await expect( + page.getByRole('button', { + name: 'user1', + }), + ).toBeVisible(); + }); + + test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { + const channelName = faker.string.uuid(); - await expect(page.locator('role=menuitem[name="Reply in direct message"]')).toHaveClass(/disabled/); - await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + + const channelMention = page.getByRole('button', { + name: 'general', }); - test('expect create a private channel, encrypt it and send an encrypted message', async ({ page }) => { - const channelName = faker.string.uuid(); + await expect(channelMention).toBeVisible(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.btnCreate.click(); + await channelMention.click(); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/channel/general`); + }); - await expect(poHomeChannel.toastSuccess).toBeVisible(); + test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.dismissToast(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await page.waitForTimeout(1000); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await poHomeChannel.content.sendMessage('hello world'); + await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); + await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); + }); - test('expect create a encrypted private channel and mention user', async ({ page }) => { - const channelName = faker.string.uuid(); + test('should encrypted field be available on edit room', async ({ page }) => { + const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/group/${channelName}`); + await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.content.sendMessage('hello @user1'); + await poHomeChannel.dismissToast(); - await expect( - page.getByRole('button', { - name: 'user1', - }), - ).toBeVisible(); - }); + await poHomeChannel.tabs.btnRoomInfo.click(); + await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); - test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { - const channelName = faker.string.uuid(); + await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); + }); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + test.fixme('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { + // delete direct message if it exists + await api.post('/im.delete', { username: 'user2' }); + await page.reload(); - await expect(page).toHaveURL(`/group/${channelName}`); + await poHomeChannel.sidenav.openNewByLabel('Direct message'); + await poHomeChannel.sidenav.inputDirectUsername.click(); + await page.keyboard.type('user2'); + await page.waitForTimeout(1000); + await page.keyboard.press('Enter'); + await poHomeChannel.sidenav.btnCreate.click(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); - await poHomeChannel.content.sendMessage('Are you in the #general channel?'); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); + await page.getByRole('button', { name: 'Enable encryption' }).click(); + await page.waitForTimeout(1000); - const channelMention = page.getByRole('button', { - name: 'general', - }); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - await expect(channelMention).toBeVisible(); + await poHomeChannel.dismissToast(); - await channelMention.click(); + await poHomeChannel.tabs.kebab.click({ force: true }); + await expect(poHomeChannel.tabs.btnEnableOTR).toBeVisible(); + await poHomeChannel.tabs.btnEnableOTR.click({ force: true }); - await expect(page).toHaveURL(`/channel/general`); - }); + await expect(page.getByText('OTR not available')).toBeVisible(); + }); - test('expect create a encrypted private channel, mention a channel and user', async ({ page }) => { + test('File and description encryption', async ({ page }) => { + await test.step('create an encrypted channel', async () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); await expect(page).toHaveURL(`/group/${channelName}`); + await poHomeChannel.dismissToast(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); - await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); + await test.step('send a file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); }); + }); - test('should encrypted field be available on edit room', async ({ page }) => { + test('File encryption with whitelisted and blacklisted media types', async ({ page, api }) => { + await test.step('create an encrypted room', async () => { const channelName = faker.string.uuid(); await poHomeChannel.sidenav.openNewByLabel('Channel'); await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); await poHomeChannel.sidenav.btnCreate.click(); await expect(page).toHaveURL(`/group/${channelName}`); - await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.dismissToast(); - await poHomeChannel.tabs.btnRoomInfo.click(); - await poHomeChannel.tabs.room.btnEdit.click(); - await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); - - await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); - test.fixme('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { - // delete direct message if it exists - await api.post('/im.delete', { username: 'user2' }); - await page.reload(); + await test.step('send a text file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 1'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - await poHomeChannel.sidenav.openNewByLabel('Direct message'); - await poHomeChannel.sidenav.inputDirectUsername.click(); - await page.keyboard.type('user2'); - await page.waitForTimeout(1000); - await page.keyboard.press('Enter'); - await poHomeChannel.sidenav.btnCreate.click(); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); - await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); + await test.step('set whitelisted media type setting', async () => { + await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); + }); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); - await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); - await page.getByRole('button', { name: 'Enable encryption' }).click(); - await page.waitForTimeout(1000); + await test.step('send text file again with whitelist setting set', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 2'); + await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + }); - await poHomeChannel.dismissToast(); + await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); - await poHomeChannel.tabs.kebab.click({ force: true }); - await expect(poHomeChannel.tabs.btnEnableOTR).toBeVisible(); - await poHomeChannel.tabs.btnEnableOTR.click({ force: true }); + await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 3'); + await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - await expect(page.getByText('OTR not available')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); }); }); - test.describe('File Encryption', async () => { - test.use({ storageState: Users.userE2EE.state }); + test.describe('File encryption setting disabled', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); + test.afterAll(async ({ api }) => { - await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' }); + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true }); await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); }); - test('File and description encryption', async ({ page }) => { + test('Upload file without encryption in e2ee room', async ({ page }) => { await test.step('create an encrypted channel', async () => { const channelName = faker.string.uuid(); @@ -595,123 +660,23 @@ test.describe('e2e-encryption', () => { await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); - await test.step('send a file in channel', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await test.step('send a test encrypted message to check e2ee is working', async () => { + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); }); - }); - - test('File encryption with whitelisted and blacklisted media types', async ({ page, api }) => { - await test.step('create an encrypted room', async () => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); - await expect(page).toHaveURL(`/group/${channelName}`); - - await poHomeChannel.dismissToast(); - - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - }); - - await test.step('send a text file in channel', async () => { + await test.step('send a text file in channel, file should not be encrypted', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 1'); + await poHomeChannel.content.descriptionInput.fill('any_description'); await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); }); - - await test.step('set whitelisted media type setting', async () => { - await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); - }); - - await test.step('send text file again with whitelist setting set', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 2'); - await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); - }); - - await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); - }); - - await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 3'); - await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); - }); - }); - - test.describe('File encryption setting disabled', async () => { - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false }); - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); - }); - - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true }); - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); - }); - - test('Upload file without encryption in e2ee room', async ({ page }) => { - await test.step('create an encrypted channel', async () => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); - - await expect(page).toHaveURL(`/group/${channelName}`); - - await poHomeChannel.dismissToast(); - - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - }); - - await test.step('send a test encrypted message to check e2ee is working', async () => { - await poHomeChannel.content.sendMessage('This is an encrypted message.'); - - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); - - await test.step('send a text file in channel, file should not be encrypted', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); - }); - }); }); }); diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 7a596f96b1675..01b7f5e09501a 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -214,6 +214,10 @@ export abstract class BaseKeyCodec { return importRawKey(encodeBinary(password)); } + deriveKey(salt: string, baseKey: CryptoKey): Promise { + return this.#crypto.deriveKeyWithPbkdf2(this.#crypto.encodeUtf8(salt).buffer, baseKey); + } + async deriveMasterKey(salt: Uint8Array, password: string): Promise { if (!password) { throw new Error('Password is required'); diff --git a/yarn.lock b/yarn.lock index d5d5f23e7a2a7..1fca5eaa3ecfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5050,14 +5050,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.52.0": - version: 1.52.0 - resolution: "@playwright/test@npm:1.52.0" +"@playwright/test@npm:~1.54.2": + version: 1.54.2 + resolution: "@playwright/test@npm:1.54.2" dependencies: - playwright: "npm:1.52.0" + playwright: "npm:1.54.2" bin: playwright: cli.js - checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88 + checksum: 10/33d6c0780ef6cca12c008cae166ba06978ae975138a1654e14cd8691ad7826dbbc03e492ed42c65889c44ca893d0fd47d8ae4dc8d824020d7f767260b395dc6b languageName: node linkType: hard @@ -7388,7 +7388,7 @@ __metadata: dependencies: "@rocket.chat/e2ee": "workspace:*" "@vitest/browser": "npm:4.0.0-beta.8" - playwright: "npm:^1.54.2" + playwright: "npm:~1.54.2" typescript: "npm:~5.9.2" vitest: "npm:4.0.0-beta.8" languageName: unknown @@ -8027,7 +8027,7 @@ __metadata: "@opentelemetry/exporter-trace-otlp-grpc": "npm:^0.54.2" "@opentelemetry/sdk-node": "npm:^0.54.2" "@parse/node-apn": "npm:^6.3.0" - "@playwright/test": "npm:^1.52.0" + "@playwright/test": "npm:~1.54.2" "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@react-pdf/renderer": "npm:^3.4.5" "@rocket.chat/account-utils": "workspace:^" @@ -9370,7 +9370,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.26.10" "@faker-js/faker": "npm:~8.0.2" - "@playwright/test": "npm:^1.52.0" + "@playwright/test": "npm:~1.54.2" "@react-spectrum/test-utils": "npm:~1.0.0-alpha.8" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" @@ -29530,33 +29530,33 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.52.0, playwright@npm:^1.14.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:1.54.2, playwright@npm:~1.54.2": + version: 1.54.2 + resolution: "playwright@npm:1.54.2" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.54.2" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec + checksum: 10/3f4fedc1e2290b6da6960eedf0d78b9097251848b499b0627186ca85e94178fe6be1590c4926246ff4dad5729fc80ab63fee6554fe736d29c0a1808ec483467f languageName: node linkType: hard -"playwright@npm:^1.54.2": - version: 1.54.2 - resolution: "playwright@npm:1.54.2" +"playwright@npm:^1.14.0": + version: 1.52.0 + resolution: "playwright@npm:1.52.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.54.2" + playwright-core: "npm:1.52.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/3f4fedc1e2290b6da6960eedf0d78b9097251848b499b0627186ca85e94178fe6be1590c4926246ff4dad5729fc80ab63fee6554fe736d29c0a1808ec483467f + checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec languageName: node linkType: hard From 6124bc690de6f668091ce3597936d13992f62aa2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 16 Aug 2025 14:04:18 -0300 Subject: [PATCH 016/251] add docs --- packages/e2ee/docs/state_v1.mmd | 42 +++++++++++++++++++ packages/e2ee/docs/state_v2.mmd | 72 +++++++++++++++++++++++++++++++++ packages/e2ee/docs/state_v3.mmd | 58 ++++++++++++++++++++++++++ packages/e2ee/docs/state_v4.mmd | 61 ++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 packages/e2ee/docs/state_v1.mmd create mode 100644 packages/e2ee/docs/state_v2.mmd create mode 100644 packages/e2ee/docs/state_v3.mmd create mode 100644 packages/e2ee/docs/state_v4.mmd diff --git a/packages/e2ee/docs/state_v1.mmd b/packages/e2ee/docs/state_v1.mmd new file mode 100644 index 0000000000000..413355c96b46a --- /dev/null +++ b/packages/e2ee/docs/state_v1.mmd @@ -0,0 +1,42 @@ +stateDiagram-v2 + direction TB + %% External events / triggers noted after colon + [*] --> NOT_STARTED: init E2E instance + NOT_STARTED --> LOADING_KEYS: startClient() + NOT_STARTED --> DISABLED: stopClient() + + LOADING_KEYS --> ENTER_PASSWORD: need user password + LOADING_KEYS --> READY: keys loaded + LOADING_KEYS --> READY: new keys created + LOADING_KEYS --> ERROR: fetch/import failure + + ENTER_PASSWORD --> LOADING_KEYS: password accepted + ENTER_PASSWORD --> ENTER_PASSWORD: wrong password + ENTER_PASSWORD --> ERROR: unrecoverable error + + READY --> SAVE_PASSWORD: random password present + SAVE_PASSWORD --> READY: user saved password + + READY --> DISABLED: stopClient() + SAVE_PASSWORD --> DISABLED: stopClient() + + READY --> ERROR: runtime error + SAVE_PASSWORD --> ERROR: runtime error + + ERROR --> NOT_STARTED: restart + DISABLED --> NOT_STARTED: startClient() + + state NOT_STARTED { + [*] --> idle + idle --> [*] + } + + note right of LOADING_KEYS + Fetch/generate keys + Maybe persist encoded key + end note + + note right of SAVE_PASSWORD + Banner + modal prompts user + to store random password + end note \ No newline at end of file diff --git a/packages/e2ee/docs/state_v2.mmd b/packages/e2ee/docs/state_v2.mmd new file mode 100644 index 0000000000000..db27c554decab --- /dev/null +++ b/packages/e2ee/docs/state_v2.mmd @@ -0,0 +1,72 @@ +stateDiagram-v2 + direction TB + + %% State styling definitions + classDef normal fill:#eef,stroke:#336,stroke-width:1px + classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px + classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px + classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px + + %% Flow start + [*] --> NOT_STARTED: init E2E instance + + %% Start / stop + NOT_STARTED --> LOADING_KEYS: startClient() + NOT_STARTED --> DISABLED: stopClient() + + %% Loading phase + LOADING_KEYS --> ENTER_PASSWORD: need user password / show password banner + modal + LOADING_KEYS --> READY: keys loaded from local & server + LOADING_KEYS --> READY: new keypair generated + LOADING_KEYS --> ERROR: fetch/import failure + + %% Password entry loop + ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted + ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (toast errors) + ENTER_PASSWORD --> ERROR: unrecoverable error + + %% Ready & save password banner + READY --> SAVE_PASSWORD: random password present -> show SavePassword banner + modal + SAVE_PASSWORD --> READY: user confirms save (modal confirm) + + %% Disable + READY --> DISABLED: stopClient() + SAVE_PASSWORD --> DISABLED: stopClient() + + %% Runtime errors + READY --> ERROR: runtime error + SAVE_PASSWORD --> ERROR: runtime error + + %% Recovery paths + ERROR --> NOT_STARTED: restart flow (startClient()) + DISABLED --> NOT_STARTED: re-enable (startClient()) + + %% Composite example (minimal placeholder) for NOT_STARTED internal idle + state NOT_STARTED { + [*] --> idle + idle --> [*] + } + + %% Notes for key phases + note right of LOADING_KEYS + Fetch or generate RSA keys + Maybe persist encoded private key to server + Decide if password required + end note + + note right of ENTER_PASSWORD + Banner+Modal: EnterE2EPasswordModal + User attempts decrypt private key + Toast on failure + end note + + note right of SAVE_PASSWORD + Banner+Modal: SaveE2EPasswordModal + Prompt user to store random password + end note + + %% Class assignments + class NOT_STARTED,READY,DISABLED normal + class LOADING_KEYS action + class ENTER_PASSWORD,SAVE_PASSWORD warning + class ERROR error \ No newline at end of file diff --git a/packages/e2ee/docs/state_v3.mmd b/packages/e2ee/docs/state_v3.mmd new file mode 100644 index 0000000000000..53bc1064325a2 --- /dev/null +++ b/packages/e2ee/docs/state_v3.mmd @@ -0,0 +1,58 @@ +stateDiagram-v2 + direction TB + + %% Style definitions (note: cannot style internal composite states per Mermaid limitations) + classDef normal fill:#eef,stroke:#336,stroke-width:1px + classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px + classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px + classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px + + [*] --> NOT_STARTED: init E2E instance + NOT_STARTED --> LOADING_KEYS: startClient() + NOT_STARTED --> DISABLED: stopClient() + + LOADING_KEYS --> ENTER_PASSWORD: need user password + LOADING_KEYS --> OPERATIONAL: keys loaded / new keypair + LOADING_KEYS --> ERROR: fetch/import failure + + ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted + ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (retry) + ENTER_PASSWORD --> ERROR: unrecoverable error + + OPERATIONAL --> DISABLED: stopClient() + OPERATIONAL --> ERROR: runtime error + + ERROR --> NOT_STARTED: restart (startClient()) + DISABLED --> NOT_STARTED: re-enable (startClient()) + + %% Composite operational state with concurrency: Ready flow || Key distribution loop + state OPERATIONAL { + direction TB + %% Region 1: User readiness / password banner flow + state ReadyFlow { + [*] --> READY + READY --> SAVE_PASSWORD: random password present + SAVE_PASSWORD --> READY: user saved password + } + -- + %% Region 2: Key distribution periodic task + [*] --> KD_IDLE + KD_IDLE --> KD_SAMPLE: interval tick (10s) + KD_SAMPLE --> KD_FETCH: sample rooms have candidates + KD_SAMPLE --> KD_IDLE: no rooms to process + KD_FETCH --> KD_PROVIDE: usersWaitingForE2EKeys found + KD_FETCH --> KD_IDLE: none waiting + KD_PROVIDE --> KD_IDLE: POST success / handled + } + + note right of OPERATIONAL + Two concurrent regions: + 1) ReadyFlow manages READY <-> SAVE_PASSWORD + 2) KeyDistribution loop runs every 10s + Loop exits implicitly when leaving OPERATIONAL + end note + + class NOT_STARTED,DISABLED normal + class LOADING_KEYS action + class ENTER_PASSWORD warning + class ERROR error \ No newline at end of file diff --git a/packages/e2ee/docs/state_v4.mmd b/packages/e2ee/docs/state_v4.mmd new file mode 100644 index 0000000000000..a571ddbd8eb13 --- /dev/null +++ b/packages/e2ee/docs/state_v4.mmd @@ -0,0 +1,61 @@ +stateDiagram-v2 + direction TB + + classDef normal fill:#eef,stroke:#336,stroke-width:1px + classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px + classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px + classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px + + [*] --> NOT_STARTED: init E2E instance + NOT_STARTED --> LOADING_KEYS: startClient() + NOT_STARTED --> DISABLED: stopClient() + + LOADING_KEYS --> ENTER_PASSWORD: need user password + LOADING_KEYS --> OPERATIONAL: keys loaded / new keypair + LOADING_KEYS --> ERROR: fetch/import failure + + ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted + ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (retry) + ENTER_PASSWORD --> ERROR: unrecoverable error + + OPERATIONAL --> DISABLED: stopClient() + OPERATIONAL --> ERROR: runtime error (global) + + ERROR --> NOT_STARTED: restart (startClient()) + DISABLED --> NOT_STARTED: re-enable (startClient()) + + state OPERATIONAL { + direction TB + state ReadyFlow { + [*] --> READY + READY --> SAVE_PASSWORD: random password present + SAVE_PASSWORD --> READY: user saved password + READY --> ERROR: decrypt / subscription failure + SAVE_PASSWORD --> ERROR: modal / banner failure + } + -- + [*] --> KD_IDLE + KD_IDLE --> KD_SAMPLE: interval tick (10s) + KD_SAMPLE --> KD_FETCH: rooms sampled + KD_SAMPLE --> KD_IDLE: none to process + KD_FETCH --> KD_PROVIDE: candidates found + KD_FETCH --> KD_IDLE: none waiting + KD_PROVIDE --> KD_IDLE: POST success + + %% Error transitions inside key distribution loop + KD_IDLE --> ERROR: interval setup fail + KD_SAMPLE --> ERROR: sampling error + KD_FETCH --> ERROR: fetch usersWaitingForE2EKeys fail + KD_PROVIDE --> ERROR: provide keys POST fail + } + + note right of OPERATIONAL + OPERATIONAL has two concurrent regions. + Each internal state can surface errors directly + to ERROR for unified recovery. + end note + + class NOT_STARTED,DISABLED normal + class LOADING_KEYS action + class ENTER_PASSWORD warning + class ERROR error \ No newline at end of file From 151d089e3a7f8d528bed7dfb5610f78d103aca02 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 11:46:52 -0300 Subject: [PATCH 017/251] import rsa decrypt key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 4 +- packages/e2ee-node/src/codec.ts | 1 + packages/e2ee-web/src/codec.ts | 1 + packages/e2ee/package.json | 5 +- packages/e2ee/src/codec.ts | 9 +- yarn.lock | 148 ++++++++++++++----- 6 files changed, 124 insertions(+), 44 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index bcc3fffe1919b..7ccf0f8d17595 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -24,7 +24,7 @@ import { decryptAES, generateRSAKey, exportJWKKey, - importRSAKey, + // importRSAKey, // deriveKey, } from './helper'; import { log, logError } from './logger'; @@ -478,7 +478,7 @@ class E2E extends Emitter { this.publicKey = public_key; try { - this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); + this.privateKey = await this.e2ee.codec.crypto.importRsaDecryptKey(EJSON.parse(private_key)); Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { diff --git a/packages/e2ee-node/src/codec.ts b/packages/e2ee-node/src/codec.ts index 44b3224dd3173..f63ac59510064 100644 --- a/packages/e2ee-node/src/codec.ts +++ b/packages/e2ee-node/src/codec.ts @@ -8,6 +8,7 @@ export default class NodeKeyCodec extends BaseKeyCodec { getRandomUint16Array: (array) => crypto.getRandomValues(array), getRandomUint32Array: (array) => crypto.getRandomValues(array), importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), generateKeyPair: () => crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts index cb3b7748ae6cd..17e268e9c5f88 100644 --- a/packages/e2ee-web/src/codec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -8,6 +8,7 @@ export default class WebKeyCodec extends BaseKeyCodec { getRandomUint16Array: (array) => crypto.getRandomValues(array), getRandomUint32Array: (array) => crypto.getRandomValues(array), importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), generateKeyPair: () => crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 8a778df685679..35e896d8bfe55 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -19,14 +19,15 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", - "lint": "oxlint", + "lint": "oxlint --type-aware", "testunit": "node --experimental-strip-types --test" }, "devDependencies": { "@noble/ciphers": "~1.3.0", "@noble/curves": "~1.9.7", "@noble/hashes": "~1.8.0", - "oxlint": "~1.11.2", + "oxlint": "~1.12.0", + "oxlint-tsgolint": "~0.0.4", "typescript": "~5.9.2" }, "license": "MIT", diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 01b7f5e09501a..d849468c7e3a4 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -1,14 +1,19 @@ export interface CryptoProvider { /** * @example - * (key) => await crypto.subtle.exportKey('jwk', key); + * (key) => await crypto.subtle.exportKey('jwk', key) */ exportJsonWebKey(key: CryptoKey): Promise; /** * @example - * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']) */ importRawKey(raw: Uint8Array): Promise; + /** + * @example + * (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']) + */ + importRsaDecryptKey(key: JsonWebKey): Promise; /** * @example * (array) => crypto.getRandomValues(array), diff --git a/yarn.lock b/yarn.lock index 1fca5eaa3ecfd..f40af11032987 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4781,6 +4781,13 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/darwin-arm64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.0.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@oxlint-tsgolint/darwin-x64@npm:0.0.2": version: 0.0.2 resolution: "@oxlint-tsgolint/darwin-x64@npm:0.0.2" @@ -4788,6 +4795,13 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/darwin-x64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.0.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@oxlint-tsgolint/linux-arm64@npm:0.0.2": version: 0.0.2 resolution: "@oxlint-tsgolint/linux-arm64@npm:0.0.2" @@ -4795,6 +4809,13 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/linux-arm64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.0.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@oxlint-tsgolint/linux-x64@npm:0.0.2": version: 0.0.2 resolution: "@oxlint-tsgolint/linux-x64@npm:0.0.2" @@ -4802,6 +4823,13 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/linux-x64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.0.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@oxlint-tsgolint/win32-arm64@npm:0.0.2": version: 0.0.2 resolution: "@oxlint-tsgolint/win32-arm64@npm:0.0.2" @@ -4809,6 +4837,13 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/win32-arm64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.0.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@oxlint-tsgolint/win32-x64@npm:0.0.2": version: 0.0.2 resolution: "@oxlint-tsgolint/win32-x64@npm:0.0.2" @@ -4816,58 +4851,65 @@ __metadata: languageName: node linkType: hard -"@oxlint/darwin-arm64@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/darwin-arm64@npm:1.11.2" +"@oxlint-tsgolint/win32-x64@npm:0.0.4": + version: 0.0.4 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.0.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/darwin-arm64@npm:1.12.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/darwin-x64@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/darwin-x64@npm:1.11.2" +"@oxlint/darwin-x64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/darwin-x64@npm:1.12.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/linux-arm64-gnu@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/linux-arm64-gnu@npm:1.11.2" +"@oxlint/linux-arm64-gnu@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.12.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-arm64-musl@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/linux-arm64-musl@npm:1.11.2" +"@oxlint/linux-arm64-musl@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.12.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/linux-x64-gnu@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/linux-x64-gnu@npm:1.11.2" +"@oxlint/linux-x64-gnu@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.12.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-x64-musl@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/linux-x64-musl@npm:1.11.2" +"@oxlint/linux-x64-musl@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/linux-x64-musl@npm:1.12.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/win32-arm64@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/win32-arm64@npm:1.11.2" +"@oxlint/win32-arm64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/win32-arm64@npm:1.12.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/win32-x64@npm:1.11.2": - version: 1.11.2 - resolution: "@oxlint/win32-x64@npm:1.11.2" +"@oxlint/win32-x64@npm:1.12.0": + version: 1.12.0 + resolution: "@oxlint/win32-x64@npm:1.12.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -7401,7 +7443,8 @@ __metadata: "@noble/ciphers": "npm:~1.3.0" "@noble/curves": "npm:~1.9.7" "@noble/hashes": "npm:~1.8.0" - oxlint: "npm:~1.11.2" + oxlint: "npm:~1.12.0" + oxlint-tsgolint: "npm:~0.0.4" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -28538,18 +28581,47 @@ __metadata: languageName: node linkType: hard -"oxlint@npm:~1.11.2": - version: 1.11.2 - resolution: "oxlint@npm:1.11.2" - dependencies: - "@oxlint/darwin-arm64": "npm:1.11.2" - "@oxlint/darwin-x64": "npm:1.11.2" - "@oxlint/linux-arm64-gnu": "npm:1.11.2" - "@oxlint/linux-arm64-musl": "npm:1.11.2" - "@oxlint/linux-x64-gnu": "npm:1.11.2" - "@oxlint/linux-x64-musl": "npm:1.11.2" - "@oxlint/win32-arm64": "npm:1.11.2" - "@oxlint/win32-x64": "npm:1.11.2" +"oxlint-tsgolint@npm:~0.0.4": + version: 0.0.4 + resolution: "oxlint-tsgolint@npm:0.0.4" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.0.4" + "@oxlint-tsgolint/darwin-x64": "npm:0.0.4" + "@oxlint-tsgolint/linux-arm64": "npm:0.0.4" + "@oxlint-tsgolint/linux-x64": "npm:0.0.4" + "@oxlint-tsgolint/win32-arm64": "npm:0.0.4" + "@oxlint-tsgolint/win32-x64": "npm:0.0.4" + dependenciesMeta: + "@oxlint-tsgolint/darwin-arm64": + optional: true + "@oxlint-tsgolint/darwin-x64": + optional: true + "@oxlint-tsgolint/linux-arm64": + optional: true + "@oxlint-tsgolint/linux-x64": + optional: true + "@oxlint-tsgolint/win32-arm64": + optional: true + "@oxlint-tsgolint/win32-x64": + optional: true + bin: + tsgolint: bin/tsgolint.js + checksum: 10/af3824231c82fc5f6d12cfcb12fea48e902ce6d229b8e10f27e61ccb2e3fb61bcf2cc8d362f78575a2a8f772532a0e1b0b8b4715ab4eea3ed97c9c6746de2d0c + languageName: node + linkType: hard + +"oxlint@npm:~1.12.0": + version: 1.12.0 + resolution: "oxlint@npm:1.12.0" + dependencies: + "@oxlint/darwin-arm64": "npm:1.12.0" + "@oxlint/darwin-x64": "npm:1.12.0" + "@oxlint/linux-arm64-gnu": "npm:1.12.0" + "@oxlint/linux-arm64-musl": "npm:1.12.0" + "@oxlint/linux-x64-gnu": "npm:1.12.0" + "@oxlint/linux-x64-musl": "npm:1.12.0" + "@oxlint/win32-arm64": "npm:1.12.0" + "@oxlint/win32-x64": "npm:1.12.0" oxlint-tsgolint: "npm:>=0.0.1" dependenciesMeta: "@oxlint/darwin-arm64": @@ -28573,7 +28645,7 @@ __metadata: bin: oxc_language_server: bin/oxc_language_server oxlint: bin/oxlint - checksum: 10/2ae18b22c9fe3aa93ea7a7049116a1ccb7cdfe22602bf58297115f1ce5aa633f508fe7b3f493f4193dc6cc00e97ba77f6f06fd895b24fdc3e89fe79c7d66d36e + checksum: 10/3f9feefdeabd77ae35e1c69e2e66a7cf428643cf6a23e7f9a4db04d07d4300c6f7048a50506988ac89e0c5b5031504bd535cd978a729029158ad56a9291aa9c5 languageName: node linkType: hard From 2a95c4e1474f048fc311c9a8f64f6b699abcd23d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 12:05:50 -0300 Subject: [PATCH 018/251] use export jwk from lib --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 14 +++++--------- packages/e2ee/src/codec.ts | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 7ccf0f8d17595..4cc32646f9574 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -23,7 +23,7 @@ import { encryptAES, decryptAES, generateRSAKey, - exportJWKKey, + // exportJWKKey, // importRSAKey, // deriveKey, } from './helper'; @@ -417,7 +417,7 @@ class E2E extends Emitter { if (!this.db_public_key || !this.db_private_key) { this.setState(E2EEState.LOADING_KEYS); - await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), await this.createRandomPassword()); + await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), await this.e2ee.createRandomPassword(5)); } const randomPassword = await this.e2ee.getRandomPassword(); @@ -500,7 +500,7 @@ class E2E extends Emitter { } try { - const publicKey = await exportJWKKey(key.publicKey); + const publicKey = await this.e2ee.codec.crypto.exportJsonWebKey(key.publicKey); this.publicKey = JSON.stringify(publicKey); Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); @@ -510,7 +510,7 @@ class E2E extends Emitter { } try { - const privateKey = await exportJWKKey(key.privateKey); + const privateKey = await this.e2ee.codec.crypto.exportJsonWebKey(key.privateKey); Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { @@ -525,14 +525,10 @@ class E2E extends Emitter { await sdk.call('e2e.requestSubscriptionKeys'); } - async createRandomPassword(): Promise { - return this.e2ee.createRandomPassword(5); - } - async encodePrivateKey(privateKey: string, password: string): Promise { const masterKey = await this.getMasterKey(password); - const vector = crypto.getRandomValues(new Uint8Array(16)); + const vector = this.e2ee.codec.crypto.getRandomUint8Array(new Uint8Array(16)); try { if (!masterKey) { throw new Error('Error getting master key'); diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index d849468c7e3a4..c705d15e76778 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -59,8 +59,8 @@ export interface CryptoProvider { * for (let i = 0; i < bytes.byteLength; i++) { * binaryString += String.fromCharCode(bytes[i]!); * } - * return btoa(binaryString); - * } + * return btoa(binaryString); + * } */ encodeBase64(input: ArrayBuffer): string; /** @@ -72,7 +72,7 @@ export interface CryptoProvider { * const buffer = new Uint8Array(); * encoder.encodeInto(text, buffer); * return buffer; - * }; + * } * ``` */ encodeBinary(input: string): Uint8Array; From 65b676fb12f3a8cdf5321f0b1d057f2beb8da3e8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 12:23:32 -0300 Subject: [PATCH 019/251] generate rsa key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 4 ++-- packages/e2ee-node/src/__tests__/codec.test.ts | 8 ++++---- packages/e2ee-node/src/codec.ts | 2 +- packages/e2ee-web/src/__tests__/codec.test.ts | 8 ++++---- packages/e2ee-web/src/codec.ts | 2 +- packages/e2ee/src/codec.ts | 10 +++++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 4cc32646f9574..f7f72ac50cf5d 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -22,7 +22,7 @@ import { splitVectorAndEcryptedData, encryptAES, decryptAES, - generateRSAKey, + // generateRSAKey, // exportJWKKey, // importRSAKey, // deriveKey, @@ -492,7 +492,7 @@ class E2E extends Emitter { this.setState(E2EEState.LOADING_KEYS); let key; try { - key = await generateRSAKey(); + key = await this.e2ee.codec.crypto.generateRsaOaepKeyPair(); this.privateKey = key.privateKey; } catch (error) { this.setState(E2EEState.ERROR); diff --git a/packages/e2ee-node/src/__tests__/codec.test.ts b/packages/e2ee-node/src/__tests__/codec.test.ts index d867ce910d8cd..5c97e3655a3a4 100644 --- a/packages/e2ee-node/src/__tests__/codec.test.ts +++ b/packages/e2ee-node/src/__tests__/codec.test.ts @@ -5,7 +5,7 @@ const createKeyCodec = () => new KeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const saltBuffer = codec.encodeSalt('salt-user-1'); const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); @@ -15,7 +15,7 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { test('KeyCodec wrong password fails', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt1 = codec.encodeSalt('salt1'); const masterKey = await codec.deriveMasterKey(salt1, 'correct'); const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); @@ -25,7 +25,7 @@ test('KeyCodec wrong password fails', async () => { test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); // const privStr = JSON.stringify(privateJWK); const salt = codec.encodeSalt('salt'); const masterKey = await codec.deriveMasterKey(salt, 'pw'); @@ -37,7 +37,7 @@ test('KeyCodec tamper detection (ciphertext)', async () => { test('KeyCodec legacy roundtrip', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); diff --git a/packages/e2ee-node/src/codec.ts b/packages/e2ee-node/src/codec.ts index f63ac59510064..26f1b82de8df3 100644 --- a/packages/e2ee-node/src/codec.ts +++ b/packages/e2ee-node/src/codec.ts @@ -9,7 +9,7 @@ export default class NodeKeyCodec extends BaseKeyCodec { getRandomUint32Array: (array) => crypto.getRandomValues(array), importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateKeyPair: () => + generateRsaOaepKeyPair: () => crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, diff --git a/packages/e2ee-web/src/__tests__/codec.test.ts b/packages/e2ee-web/src/__tests__/codec.test.ts index 29eec246c50ad..f0ce901af9d76 100644 --- a/packages/e2ee-web/src/__tests__/codec.test.ts +++ b/packages/e2ee-web/src/__tests__/codec.test.ts @@ -5,7 +5,7 @@ const createKeyCodec = () => new KeyCodec(); test('KeyCodec roundtrip (v1 structured encoding)', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const saltBuffer = codec.encodeSalt('salt-user-1'); const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); @@ -16,7 +16,7 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { test('KeyCodec wrong password fails', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt1 = codec.encodeSalt('salt1'); const masterKey = await codec.deriveMasterKey(salt1, 'correct'); const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); @@ -26,7 +26,7 @@ test('KeyCodec wrong password fails', async () => { test('KeyCodec tamper detection (ciphertext)', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt = codec.encodeSalt('salt'); const masterKey = await codec.deriveMasterKey(salt, 'pw'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); @@ -37,7 +37,7 @@ test('KeyCodec tamper detection (ciphertext)', async () => { test('KeyCodec legacy roundtrip', async () => { const codec = createKeyCodec(); - const { privateJWK } = await codec.generateRSAKeyPair(); + const { privateJWK } = await codec.generateRsaOaepKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts index 17e268e9c5f88..632a41f1747d3 100644 --- a/packages/e2ee-web/src/codec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -9,7 +9,7 @@ export default class WebKeyCodec extends BaseKeyCodec { getRandomUint32Array: (array) => crypto.getRandomValues(array), importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateKeyPair: () => + generateRsaOaepKeyPair: () => crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index c705d15e76778..2823c5275ec25 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -31,13 +31,13 @@ export interface CryptoProvider { getRandomUint32Array(array: Uint32Array): Uint32Array; /** * @example - * crypto.subtle.generateKey( + * () => crypto.subtle.generateKey( * { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, * true, * ['encrypt', 'decrypt'], * ) */ - generateKeyPair(): Promise; + generateRsaOaepKeyPair(): Promise; /** * @example * (input) => { @@ -161,10 +161,10 @@ export abstract class BaseKeyCodec { this.#crypto = crypto; } - async generateRSAKeyPair(): Promise { - const { generateKeyPair, exportJsonWebKey } = this.#crypto; + async generateRsaOaepKeyPair(): Promise { + const { generateRsaOaepKeyPair, exportJsonWebKey } = this.#crypto; - const keyPair = await generateKeyPair(); + const keyPair = await generateRsaOaepKeyPair(); const publicJWK = await exportJsonWebKey(keyPair.publicKey); const privateJWK = await exportJsonWebKey(keyPair.privateKey); return { privateJWK, publicJWK }; From b9e1b1c5c16a38d82375b4797dd3e29fb3bc4843 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 12:33:39 -0300 Subject: [PATCH 020/251] decrypt aes cbc --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index f7f72ac50cf5d..b64b83e948e8b 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -21,7 +21,7 @@ import { joinVectorAndEcryptedData, splitVectorAndEcryptedData, encryptAES, - decryptAES, + // decryptAES, // generateRSAKey, // exportJWKKey, // importRSAKey, @@ -635,7 +635,7 @@ class E2E extends Emitter { if (!masterKey) { throw new Error('Error getting master key'); } - const privKey = await decryptAES(vector, masterKey, cipherText); + const privKey = await this.e2ee.codec.crypto.decryptAesCbc(masterKey, vector, cipherText); const privateKey = toString(privKey) as string; if (this.db_public_key && privateKey) { @@ -665,7 +665,7 @@ class E2E extends Emitter { if (!masterKey) { throw new Error('Error getting master key'); } - const privKey = await decryptAES(vector, masterKey, cipherText); + const privKey = await this.e2ee.codec.crypto.decryptAesCbc(masterKey, vector, cipherText); return toString(privKey); } catch { this.setState(E2EEState.ENTER_PASSWORD); From bea26f00b7ca90e4f9db2468a2046fd42098a528 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 12:39:49 -0300 Subject: [PATCH 021/251] encrypt aes cbc --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index b64b83e948e8b..b3ec6021156db 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -20,7 +20,7 @@ import { toArrayBuffer, joinVectorAndEcryptedData, splitVectorAndEcryptedData, - encryptAES, + // encryptAES, // decryptAES, // generateRSAKey, // exportJWKKey, @@ -533,7 +533,7 @@ class E2E extends Emitter { if (!masterKey) { throw new Error('Error getting master key'); } - const encodedPrivateKey = await encryptAES(vector, masterKey, toArrayBuffer(privateKey)); + const encodedPrivateKey = await this.e2ee.codec.crypto.encryptAesCbc(masterKey, vector, toArrayBuffer(privateKey)); return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey)); } catch (error) { From d2f50519eaeb69f610918444cd8922b46d953ad6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 13:04:08 -0300 Subject: [PATCH 022/251] remove keys from local storage --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 3 +-- packages/e2ee/src/index.ts | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index b3ec6021156db..1959a5dcd2080 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -438,8 +438,7 @@ class E2E extends Emitter { this.log('-> Stop Client'); this.closeAlert(); - Accounts.storageLocation.removeItem('public_key'); - Accounts.storageLocation.removeItem('private_key'); + await this.e2ee.removeKeysFromLocalStorage(); this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 5d12e547cfa9c..96de8737e58f5 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -45,6 +45,11 @@ export abstract class BaseE2EE { }; } + async removeKeysFromLocalStorage(): Promise { + await this.#storage.remove('public_key'); + await this.#storage.remove('private_key'); + } + async getKeysFromService(): Promise { const keys = await this.#service.fetchMyKeys(); @@ -68,7 +73,7 @@ export abstract class BaseE2EE { } async createRandomPassword(length: number): Promise { const randomPassword = await this.#codec.generateMnemonicPhrase(length); - this.storeRandomPassword(randomPassword); + await this.storeRandomPassword(randomPassword); return randomPassword; } } From 2efcd0887fc7f6947f89daeac74351d683ac725e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 15:28:28 -0300 Subject: [PATCH 023/251] get master key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 29 ++------ apps/meteor/tests/e2e/e2e-encryption.spec.ts | 78 ++++++++++---------- packages/e2ee/.oxlintrc.json | 3 +- packages/e2ee/src/codec.ts | 19 +++-- packages/e2ee/src/index.ts | 32 +++++++- packages/e2ee/src/utils.ts | 16 ++++ 6 files changed, 110 insertions(+), 67 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 1959a5dcd2080..7bd81f7654b1e 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -526,8 +526,7 @@ class E2E extends Emitter { async encodePrivateKey(privateKey: string, password: string): Promise { const masterKey = await this.getMasterKey(password); - - const vector = this.e2ee.codec.crypto.getRandomUint8Array(new Uint8Array(16)); + const vector = this.e2ee.codec.getRandomUint8Array(16); try { if (!masterKey) { throw new Error('Error getting master key'); @@ -542,19 +541,6 @@ class E2E extends Emitter { } async getMasterKey(password: string): Promise { - if (password == null) { - alert('You should provide a password'); - } - - // First, create a PBKDF2 "key" containing the password - let baseKey; - try { - baseKey = await this.e2ee.codec.createBaseKey(password); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error creating a key based on user password: ', error); - } - const userId = Meteor.userId(); if (!userId) { @@ -562,13 +548,14 @@ class E2E extends Emitter { return this.error('User not found'); } - // Derive a key from the password - try { - return await this.e2ee.codec.deriveKey(userId, baseKey); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error deriving baseKey: ', error); + const res = await this.e2ee.getMasterKey(password, userId); + + if (res.isOk) { + return res.value; } + + this.setState(E2EEState.ERROR); + return this.error('Error getting master key: ', res.error); } openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 179c11ce4a1d0..be4e0190461a2 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -1060,13 +1060,7 @@ test.describe.fixme('e2ee room setup', () => { }); test.describe('e2ee support legacy formats', () => { - test.use({ storageState: Users.userE2EE.state }); - - let poHomeChannel: HomeChannel; - - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - }); + test.use({ storageState: Users.admin.state }); test.beforeAll(async ({ api }) => { await api.post('/settings/E2E_Enable', { value: true }); @@ -1078,43 +1072,51 @@ test.describe('e2ee support legacy formats', () => { await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); }); - // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format - test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { - await page.goto('/home'); + test.describe('user', () => { + test.use({ storageState: Users.userE2EE.state }); + let poHomeChannel: HomeChannel; - const channelName = faker.string.uuid(); + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); - await poHomeChannel.sidenav.createEncryptedChannel(channelName); + // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format + test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { + const channelName = faker.string.uuid(); - await expect(page).toHaveURL(`/group/${channelName}`); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); - const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); - - // send old format encrypted message via API - const msg = await page.evaluate(async (rid) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/app/e2e/client/rocketchat.e2e.ts'); - const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); - }, rid); - - await request.post(`${BASE_API_URL}/chat.sendMessage`, { - headers: { - 'X-Auth-Token': Users.userE2EE.data.loginToken, - 'X-User-Id': Users.userE2EE.data._id, - }, - data: { - message: { - rid, - msg, - t: 'e2e', + // send old format encrypted message via API + const msg = await page.evaluate(async (rid) => { + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path + const { e2e } = require('/app/e2e/client/rocketchat.e2e.ts'); + const e2eRoom = await e2e.getInstanceByRoomId(rid); + return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); + }, rid); + + await request.post(`${BASE_API_URL}/chat.sendMessage`, { + headers: { + 'X-Auth-Token': Users.userE2EE.data.loginToken, + 'X-User-Id': Users.userE2EE.data._id, }, - }, - }); + data: { + message: { + rid, + msg, + t: 'e2e', + }, + }, + }); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); }); }); diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index 627b1fd9bb89b..61e5a8453d8bd 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -10,6 +10,7 @@ }, "rules": { "oxc/no-map-spread": "error", - "oxc/no-async-await": "allow" + "oxc/no-async-await": "allow", + "eslint/id-length": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 2823c5275ec25..7d6ab8d73740f 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -170,10 +170,18 @@ export abstract class BaseKeyCodec { return { privateJWK, publicJWK }; } + getRandomUint8Array(length: number): Uint8Array { + return this.#crypto.getRandomUint8Array(new Uint8Array(length)); + } + + getRandomUint32Array(length: number): Uint32Array { + return this.#crypto.getRandomUint32Array(new Uint32Array(length)); + } + async encodePrivateKey(privateKeyJwk: JsonWebKey, masterKey: CryptoKey): Promise { - const { getRandomUint8Array, encodeBase64, encryptAesCbc, encodeUtf8 } = this.#crypto; + const { encodeBase64, encryptAesCbc, encodeUtf8 } = this.#crypto; const IV_LENGTH = 16; - const iv = getRandomUint8Array(new Uint8Array(IV_LENGTH)); + const iv = this.getRandomUint8Array(IV_LENGTH); const data = encodeUtf8(JSON.stringify(privateKeyJwk)); const ct = await encryptAesCbc(masterKey, iv, data); @@ -243,11 +251,11 @@ export abstract class BaseKeyCodec { // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { - const { getRandomUint8Array, encodeUtf8, encryptAesCbc } = this.#crypto; + const { encodeUtf8, encryptAesCbc } = this.#crypto; const IV_LENGTH = 16; const salt = encodeUtf8(saltStr); const masterKey = await this.deriveMasterKey(salt, password); - const iv = getRandomUint8Array(new Uint8Array(IV_LENGTH)); + const iv = this.getRandomUint8Array(IV_LENGTH); const data = encodeUtf8(privateKeyJwkString); const ct = await encryptAesCbc(masterKey, iv, data); const ctBytes = new Uint8Array(ct); @@ -280,8 +288,7 @@ export abstract class BaseKeyCodec { async generateMnemonicPhrase(length: number): Promise { const { v1 } = await import('./word-list.ts'); - const randomBuffer = new Uint32Array(length); - this.#crypto.getRandomUint32Array(randomBuffer); + const randomBuffer = this.getRandomUint32Array(length); return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 96de8737e58f5..52a755db67a3c 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,5 +1,5 @@ +import { type Optional, type Result, err, ok } from './utils.ts'; import type { BaseKeyCodec } from './codec.ts'; -import type { Optional } from './utils.ts'; export { BaseKeyCodec } from './codec.ts'; @@ -36,6 +36,36 @@ export abstract class BaseE2EE { this.#codec = codec; } + async getMasterKey(password: string, userId: string): Promise> { + if (!password) { + return err(new Error('You should provide a password')); + } + + // First, create a PBKDF2 "key" containing the password + const baseKey = await (async () => { + try { + return ok(await this.#codec.createBaseKey(password)); + } catch (error) { + return err(new Error(`Error creating a key based on user password: ${error}`)); + } + })(); + + if (!baseKey.isOk) { + return baseKey; + } + + if (!userId) { + throw new Error('User not found'); + } + + // Derive a key from the password + try { + return ok(await this.#codec.deriveKey(userId, baseKey.value)); + } catch (error) { + return err(new Error(`Error deriving baseKey: ${error}`)); + } + } + async getKeysFromLocalStorage(): Promise> { const public_key = await this.#storage.load('public_key'); const private_key = await this.#storage.load('private_key'); diff --git a/packages/e2ee/src/utils.ts b/packages/e2ee/src/utils.ts index afc72c90ec7cc..0cd002d6daaca 100644 --- a/packages/e2ee/src/utils.ts +++ b/packages/e2ee/src/utils.ts @@ -1,3 +1,19 @@ export type Optional = { [Key in keyof Obj]: Obj[Key] | null; }; + +export interface Ok { + isOk: true; + value: V; +} + +export interface Err { + isOk?: undefined; // or isErr: true + error: E; +} + +export type Result = Ok | Err; + +export const ok = (value: V): Ok => ({ isOk: true, value }); + +export const err = (error: E): Err => ({ error }); From bd6e800f1e40612e1a246fbb1a29a746d469c485 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 15:35:32 -0300 Subject: [PATCH 024/251] update deps --- apps/meteor/package.json | 6 ++--- yarn.lock | 51 +++++++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 4fd55b7fe3a63..07d9d374af364 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -179,7 +179,7 @@ "eslint-plugin-anti-trojan-source": "~1.1.1", "eslint-plugin-import": "~2.31.0", "eslint-plugin-no-floating-promise": "~2.0.0", - "eslint-plugin-playwright": "~2.2.0", + "eslint-plugin-playwright": "~2.2.2", "eslint-plugin-prettier": "~5.2.6", "eslint-plugin-react": "~7.37.5", "eslint-plugin-react-hooks": "~5.0.0", @@ -193,8 +193,8 @@ "nyc": "^17.1.0", "outdent": "~0.8.0", "pino-pretty": "^7.6.1", - "playwright-core": "~1.52.0", - "playwright-qase-reporter": "^2.1.3", + "playwright-core": "~1.54.2", + "playwright-qase-reporter": "^2.1.5", "postcss": "~8.4.49", "postcss-custom-properties": "^14.0.6", "postcss-easy-import": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index f40af11032987..69a09b48124c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8289,7 +8289,7 @@ __metadata: eslint-plugin-anti-trojan-source: "npm:~1.1.1" eslint-plugin-import: "npm:~2.31.0" eslint-plugin-no-floating-promise: "npm:~2.0.0" - eslint-plugin-playwright: "npm:~2.2.0" + eslint-plugin-playwright: "npm:~2.2.2" eslint-plugin-prettier: "npm:~5.2.6" eslint-plugin-react: "npm:~7.37.5" eslint-plugin-react-hooks: "npm:~5.0.0" @@ -8368,8 +8368,8 @@ __metadata: path-to-regexp: "npm:^6.3.0" pino: "npm:^8.21.0" pino-pretty: "npm:^7.6.1" - playwright-core: "npm:~1.52.0" - playwright-qase-reporter: "npm:^2.1.3" + playwright-core: "npm:~1.54.2" + playwright-qase-reporter: "npm:^2.1.5" postcss: "npm:~8.4.49" postcss-custom-properties: "npm:^14.0.6" postcss-easy-import: "npm:^4.0.0" @@ -19774,14 +19774,14 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-playwright@npm:~2.2.0": - version: 2.2.0 - resolution: "eslint-plugin-playwright@npm:2.2.0" +"eslint-plugin-playwright@npm:~2.2.2": + version: 2.2.2 + resolution: "eslint-plugin-playwright@npm:2.2.2" dependencies: globals: "npm:^13.23.0" peerDependencies: eslint: ">=8.40.0" - checksum: 10/f17255ab715a7b57b30080d3b279b4088041bcc4733c4727ba495052ce500a5bd020b4235287da48e4251281d39039e1d55ab37904cbc4522dcf0a1ae6775b39 + checksum: 10/861bb486e30e607a3c38bf46b69b01e759a2e7db3d7e79eabb18aa006c505b632b6c6f24e8402b59b8a9b5f94a72ed454fd5a934265081028a1ca0dd4582019f languageName: node linkType: hard @@ -21141,6 +21141,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.4": + version: 4.0.4 + resolution: "form-data@npm:4.0.4" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb + languageName: node + linkType: hard + "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -29571,7 +29584,7 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0, playwright-core@npm:>=1.2.0, playwright-core@npm:~1.52.0": +"playwright-core@npm:1.52.0, playwright-core@npm:>=1.2.0": version: 1.52.0 resolution: "playwright-core@npm:1.52.0" bin: @@ -29580,7 +29593,7 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.54.2": +"playwright-core@npm:1.54.2, playwright-core@npm:~1.54.2": version: 1.54.2 resolution: "playwright-core@npm:1.54.2" bin: @@ -29589,16 +29602,16 @@ __metadata: languageName: node linkType: hard -"playwright-qase-reporter@npm:^2.1.3": - version: 2.1.3 - resolution: "playwright-qase-reporter@npm:2.1.3" +"playwright-qase-reporter@npm:^2.1.5": + version: 2.1.5 + resolution: "playwright-qase-reporter@npm:2.1.5" dependencies: chalk: "npm:^4.1.2" - qase-javascript-commons: "npm:~2.3.3" + qase-javascript-commons: "npm:~2.4.1" uuid: "npm:^9.0.0" peerDependencies: "@playwright/test": ">=1.16.3" - checksum: 10/4427e4b4c7a0916358ab6116791da5fc05cc065d1eae95c8ca0c49f14b44fc81e0054fee57227e6eec4edfc1b2a38460e73bb402676774621f26a1e6d81ada37 + checksum: 10/7f0b5079563fe9207c8a6eb5c48f48cfac9e9d21cb4b058264843c875f93789313e8bdf761a77f9596dc04a5bd915e4cf7c397bd49be1dd5fe0e1237cf7065be languageName: node linkType: hard @@ -30877,15 +30890,15 @@ __metadata: languageName: node linkType: hard -"qase-javascript-commons@npm:~2.3.3": - version: 2.3.5 - resolution: "qase-javascript-commons@npm:2.3.5" +"qase-javascript-commons@npm:~2.4.1": + version: 2.4.1 + resolution: "qase-javascript-commons@npm:2.4.1" dependencies: ajv: "npm:^8.12.0" async-mutex: "npm:~0.5.0" chalk: "npm:^4.1.2" env-schema: "npm:^5.2.0" - form-data: "npm:^4.0.0" + form-data: "npm:^4.0.4" lodash.get: "npm:^4.4.2" lodash.merge: "npm:^4.6.2" lodash.mergewith: "npm:^4.6.2" @@ -30894,7 +30907,7 @@ __metadata: qase-api-v2-client: "npm:~1.0.1" strip-ansi: "npm:^6.0.1" uuid: "npm:^9.0.0" - checksum: 10/8028f9424bf97054f1974c3b663a438f97ebf2f3f487cf9260a3d28047abe2883c870087f1619e67ce5334cc3c88361c08e9566b36052fd316b2bac3096e0712 + checksum: 10/6a0b4263414fcbee2a5bed562a9dd14cff2ce780590a92e73aa43424231495f58db2031f54d0fee8d7ffd8f92256702af668af2a50b996603b0e7c21b1f26582 languageName: node linkType: hard From fdbb375d1e7216ee09c7163b92e16df18ab2a5a5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 16:10:58 -0300 Subject: [PATCH 025/251] userId --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 10 ++-------- packages/e2ee-node/src/__tests__/e2ee.test.ts | 3 +++ packages/e2ee-web/src/__tests__/e2ee.test.ts | 5 ++++- packages/e2ee-web/src/index.ts | 19 +++++++++++++++++++ packages/e2ee/src/index.ts | 7 +++++-- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 7bd81f7654b1e..f86e9f827bb2f 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -85,6 +85,7 @@ class E2E extends Emitter { remove: (keyName) => Promise.resolve(Accounts.storageLocation.removeItem(keyName)), }, { + userId: () => Promise.resolve(Meteor.userId()), fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), }, ); @@ -541,14 +542,7 @@ class E2E extends Emitter { } async getMasterKey(password: string): Promise { - const userId = Meteor.userId(); - - if (!userId) { - this.setState(E2EEState.ERROR); - return this.error('User not found'); - } - - const res = await this.e2ee.getMasterKey(password, userId); + const res = await this.e2ee.getMasterKey(password); if (res.isOk) { return res.value; diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index 0017da5ee9dea..a6db53d9ff438 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -4,6 +4,9 @@ import E2EE from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; class MockedKeyService implements KeyService { + userId() { + return Promise.resolve('mocked_user_id'); + } fetchMyKeys(): Promise { return Promise.resolve({ public_key: 'mocked_public_key', diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts index 0017da5ee9dea..124b85f4c172f 100644 --- a/packages/e2ee-web/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-web/src/__tests__/e2ee.test.ts @@ -4,6 +4,9 @@ import E2EE from '../index.ts'; import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; class MockedKeyService implements KeyService { + userId() { + return Promise.resolve('mocked_user_id'); + } fetchMyKeys(): Promise { return Promise.resolve({ public_key: 'mocked_public_key', @@ -17,7 +20,7 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. const mockedKeyService = new MockedKeyService(); - const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const e2ee = E2EE.withLocalStorage(mockedKeyService); const pwd = await e2ee.createRandomPassword(5); assert.equal(pwd.split(' ').length, 5); assert.equal(await e2ee.getRandomPassword(), pwd); diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts index fd4c7b1e4dc9d..2fa8994c33ff0 100644 --- a/packages/e2ee-web/src/index.ts +++ b/packages/e2ee-web/src/index.ts @@ -17,6 +17,20 @@ class MemoryStorage implements KeyStorage { } } +class LocalStorage implements KeyStorage { + load(keyName: string): Promise { + return Promise.resolve(localStorage.getItem(keyName)); + } + store(keyName: string, value: string): Promise { + localStorage.setItem(keyName, value); + return Promise.resolve(); + } + remove(keyName: string): Promise { + localStorage.removeItem(keyName); + return Promise.resolve(); + } +} + export default class WebE2EE extends BaseE2EE { constructor(keyStorage: KeyStorage, keyService: KeyService) { super(new KeyCodec(), keyStorage, keyService); @@ -26,4 +40,9 @@ export default class WebE2EE extends BaseE2EE { const memoryStorage = new MemoryStorage(); return new WebE2EE(memoryStorage, keyService); } + + static withLocalStorage(keyService: KeyService): WebE2EE { + const localStorage = new LocalStorage(); + return new WebE2EE(localStorage, keyService); + } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 52a755db67a3c..7dd879b10be17 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -10,6 +10,7 @@ export interface KeyStorage { } export interface KeyService { + userId: () => Promise; fetchMyKeys(): Promise; } @@ -36,7 +37,7 @@ export abstract class BaseE2EE { this.#codec = codec; } - async getMasterKey(password: string, userId: string): Promise> { + async getMasterKey(password: string): Promise> { if (!password) { return err(new Error('You should provide a password')); } @@ -54,8 +55,10 @@ export abstract class BaseE2EE { return baseKey; } + const userId = await this.#service.userId(); + if (!userId) { - throw new Error('User not found'); + return err(new Error('User not found')); } // Derive a key from the password From 25e59b8ae5c710b5282a91e262ae6de9ebc1507c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 17:46:49 -0300 Subject: [PATCH 026/251] set public key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 2 +- packages/e2ee/src/index.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index f86e9f827bb2f..7992a50683f4f 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -503,7 +503,7 @@ class E2E extends Emitter { const publicKey = await this.e2ee.codec.crypto.exportJsonWebKey(key.publicKey); this.publicKey = JSON.stringify(publicKey); - Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); + await this.e2ee.setPublicKey(key.publicKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error exporting public key: ', error); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 7dd879b10be17..10d902f834bd3 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -37,6 +37,18 @@ export abstract class BaseE2EE { this.#codec = codec; } + async setPublicKey(key: CryptoKey): Promise { + const exported = await this.#codec.crypto.exportJsonWebKey(key); + const stringified = JSON.stringify(exported); + return this.#storage.store('public_key', stringified); + } + + async setPrivateKey(key: CryptoKey): Promise { + const exported = await this.#codec.crypto.exportJsonWebKey(key); + const stringified = JSON.stringify(exported); + return this.#storage.store('private_key', stringified); + } + async getMasterKey(password: string): Promise> { if (!password) { return err(new Error('You should provide a password')); From 0f3920aadcb4c501f9b83f0cb29296289fff846c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 18 Aug 2025 17:55:35 -0300 Subject: [PATCH 027/251] set private key --- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 7992a50683f4f..164096638e530 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -510,9 +510,7 @@ class E2E extends Emitter { } try { - const privateKey = await this.e2ee.codec.crypto.exportJsonWebKey(key.privateKey); - - Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); + await this.e2ee.setPrivateKey(key.privateKey); } catch (error) { this.setState(E2EEState.ERROR); return this.error('Error exporting private key: ', error); From ef479b545dab1cbcefa2c66a1dfa395d5cc273ef Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 19 Aug 2025 15:59:48 -0300 Subject: [PATCH 028/251] remove unused imports --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 13 +------------ packages/e2ee/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 7f9e7b4f896ac..ddf2547835dca 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -14,18 +14,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import { E2EEState } from './E2EEState'; -import { - toString, - toArrayBuffer, - joinVectorAndEcryptedData, - splitVectorAndEcryptedData, - // encryptAES, - // decryptAES, - // generateRSAKey, - // exportJWKKey, - // importRSAKey, - // deriveKey, -} from './helper'; +import { toString, toArrayBuffer, joinVectorAndEcryptedData, splitVectorAndEcryptedData } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 10d902f834bd3..cc5ddc391d339 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -11,7 +11,7 @@ export interface KeyStorage { export interface KeyService { userId: () => Promise; - fetchMyKeys(): Promise; + fetchMyKeys: () => Promise; } export type PrivateKey = string; From 4c2d68459a8e98cbc9a7f44ff426a66372b29829 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 14:41:33 -0300 Subject: [PATCH 029/251] fix some flakiness --- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 384 +++++++++++-------- 1 file changed, 215 insertions(+), 169 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 6513d3783604e..9aa735d2233ad 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -21,22 +21,23 @@ import { FileUploadModal } from './page-objects/fragments/file-upload-modal'; import { LoginPage } from './page-objects/login'; import { test, expect } from './utils/test'; -test.use({ storageState: Users.admin.state }); - -test.beforeAll(async ({ api }) => { +test.beforeAll(async () => { await injectInitialData(); - await api.post('/settings/E2E_Enable', { value: true }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); -}); - -test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); }); test.describe('initial setup', () => { test.use({ storageState: Users.admin.state }); + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: false }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + test.beforeEach(async ({ api, page }) => { const loginPage = new LoginPage(page); @@ -131,6 +132,18 @@ test.describe('initial setup', () => { }); test.describe('basic features', () => { + test.use({ storageState: Users.admin.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: false }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + test.beforeEach(async ({ api, page }) => { const loginPage = new LoginPage(page); @@ -303,10 +316,21 @@ test.describe('basic features', () => { }); }); -test.describe('e2e-encryption', () => { +test.describe.serial('e2e-encryption', () => { let poHomeChannel: HomeChannel; test.use({ storageState: Users.userE2EE.state }); + + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: false }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + }); + test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); await page.goto('/home'); @@ -422,6 +446,8 @@ test.describe('e2e-encryption', () => { await poHomeChannel.dismissToast(); + // TODO: Fix the kebab menu disappearing flakiness + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await poHomeChannel.tabs.kebab.click({ force: true }); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); @@ -448,11 +474,11 @@ test.describe('e2e-encryption', () => { await poHomeChannel.content.sendMessage('hello @user1'); - await expect( - page.getByRole('button', { - name: 'user1', - }), - ).toBeVisible(); + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); }); test('expect create a encrypted private channel, mention a channel and navigate to it', async ({ page }) => { @@ -466,7 +492,7 @@ test.describe('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel?'); - const channelMention = page.getByRole('button', { + const channelMention = await page.getByRole('button', { name: 'general', }); @@ -488,8 +514,16 @@ test.describe('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); - await expect(page.getByRole('button', { name: 'user1' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'general' })).toBeVisible(); + const channelMention = await page.getByRole('button', { + name: 'general', + }); + + const userMention = await page.getByRole('button', { + name: 'user1', + }); + + await expect(userMention).toBeVisible(); + await expect(channelMention).toBeVisible(); }); test('should encrypted field be available on edit room', async ({ page }) => { @@ -512,11 +546,7 @@ test.describe('e2e-encryption', () => { await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); }); - test.fixme('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page, api }) => { - // delete direct message if it exists - await api.post('/im.delete', { username: 'user2' }); - await page.reload(); - + test('expect create a Direct message, encrypt it and attempt to enable OTR', async ({ page }) => { await poHomeChannel.sidenav.openNewByLabel('Direct message'); await poHomeChannel.sidenav.inputDirectUsername.click(); await page.keyboard.type('user2'); @@ -527,6 +557,15 @@ test.describe('e2e-encryption', () => { await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); await poHomeChannel.tabs.kebab.click({ force: true }); + + // Disable encryption if enabled + if (await poHomeChannel.tabs.btnDisableE2E.isVisible()) { + await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); + await page.getByRole('button', { name: 'Disable encryption' }).click(); + await page.getByRole('button', { name: 'Dismiss alert' }).click(); + await poHomeChannel.tabs.kebab.click({ force: true }); + } + await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); @@ -544,107 +583,43 @@ test.describe('e2e-encryption', () => { await expect(page.getByText('OTR not available')).toBeVisible(); }); - test('File and description encryption', async ({ page }) => { - await test.step('create an encrypted channel', async () => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); - - await expect(page).toHaveURL(`/group/${channelName}`); - - await poHomeChannel.dismissToast(); - - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - }); - - await test.step('send a file in channel', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); - }); - }); - - test('File encryption with whitelisted and blacklisted media types', async ({ page, api }) => { - await test.step('create an encrypted room', async () => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); - - await expect(page).toHaveURL(`/group/${channelName}`); - - await poHomeChannel.dismissToast(); - - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - }); - - await test.step('send a text file in channel', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 1'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); - await poHomeChannel.content.btnModalConfirm.click(); - - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); - }); - - await test.step('set whitelisted media type setting', async () => { - await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); + test.describe('File Encryption', async () => { + test.afterAll(async ({ api }) => { + await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: '' }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); }); - await test.step('send text file again with whitelist setting set', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 2'); - await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + test('File and description encryption', async ({ page }) => { + await test.step('create an encrypted channel', async () => { + const channelName = faker.string.uuid(); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); - }); + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); - await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); - }); + await expect(page).toHaveURL(`/group/${channelName}`); - await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { - await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('message 3'); - await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); - await poHomeChannel.content.btnModalConfirm.click(); + await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); - }); - }); + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); - test.describe('File encryption setting disabled', async () => { - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false }); - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); - }); + await test.step('send a file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true }); - await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); }); - test('Upload file without encryption in e2ee room', async ({ page }) => { - await test.step('create an encrypted channel', async () => { + test('File encryption with whitelisted and blacklisted media types', async ({ page, api }) => { + await test.step('create an encrypted room', async () => { const channelName = faker.string.uuid(); await poHomeChannel.sidenav.openNewByLabel('Channel'); @@ -660,27 +635,98 @@ test.describe('e2e-encryption', () => { await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); - await test.step('send a test encrypted message to check e2ee is working', async () => { - await poHomeChannel.content.sendMessage('This is an encrypted message.'); + await test.step('send a text file in channel', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 1'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 1'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); + + await test.step('set whitelisted media type setting', async () => { + await api.post('/settings/FileUpload_MediaTypeWhiteList', { value: 'text/plain' }); }); - await test.step('send a text file in channel, file should not be encrypted', async () => { + await test.step('send text file again with whitelist setting set', async () => { await poHomeChannel.content.dragAndDropTxtFile(); - await poHomeChannel.content.descriptionInput.fill('any_description'); - await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.descriptionInput.fill('message 2'); + await poHomeChannel.content.fileNameInput.fill('any_file2.txt'); await poHomeChannel.content.btnModalConfirm.click(); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); - await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); - await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + }); + + await test.step('set blacklisted media type setting to not accept application/octet-stream media type', async () => { + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); + + await test.step('send text file again with blacklisted setting set, file upload should fail', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('message 3'); + await poHomeChannel.content.fileNameInput.fill('any_file3.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('message 2'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file2.txt'); + }); + }); + + test.describe('File encryption setting disabled', async () => { + test.beforeAll(async ({ api }) => { + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: false }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'application/octet-stream' }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true }); + await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); + }); + + test('Upload file without encryption in e2ee room', async ({ page }) => { + await test.step('create an encrypted channel', async () => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.advancedSettingsAccordion.click(); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + }); + + await test.step('send a test encrypted message to check e2ee is working', async () => { + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + await test.step('send a text file in channel, file should not be encrypted', async () => { + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).not.toBeVisible(); + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); }); }); }); - test.fixme('expect slash commands to be enabled in an e2ee room', async ({ page }) => { + test('expect slash commands to be enabled in an e2ee room', async ({ page }) => { test.skip(!IS_EE, 'Premium Only'); const channelName = faker.string.uuid(); @@ -706,7 +752,7 @@ test.describe('e2e-encryption', () => { await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); }); - test.describe.fixme('un-encrypted messages not allowed in e2ee rooms', () => { + test.describe('un-encrypted messages not allowed in e2ee rooms', () => { test.skip(!IS_EE, 'Premium Only'); let poHomeChannel: HomeChannel; @@ -868,7 +914,7 @@ test.describe('e2e-encryption', () => { test.use({ storageState: Users.admin.state }); -test.describe.fixme('e2ee room setup', () => { +test.describe.serial('e2ee room setup', () => { let poAccountProfile: AccountProfile; let poHomeChannel: HomeChannel; let e2eePassword: string; @@ -897,7 +943,7 @@ test.describe.fixme('e2ee room setup', () => { await poAccountProfile.securityE2EEncryptionSection.click(); await poAccountProfile.securityE2EEncryptionResetKeyButton.click(); - await page.locator('role=button[name="Login"]').waitFor(); + await page.waitForURL('/home'); await injectInitialData(); await restoreState(page, Users.admin); @@ -1060,7 +1106,13 @@ test.describe.fixme('e2ee room setup', () => { }); test.describe('e2ee support legacy formats', () => { - test.use({ storageState: Users.admin.state }); + test.use({ storageState: Users.userE2EE.state }); + + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); test.beforeAll(async ({ api }) => { await api.post('/settings/E2E_Enable', { value: true }); @@ -1072,51 +1124,45 @@ test.describe('e2ee support legacy formats', () => { await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); }); - test.describe('user', () => { - test.use({ storageState: Users.userE2EE.state }); - let poHomeChannel: HomeChannel; - - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - await page.goto('/home'); - }); - - // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format - test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.createEncryptedChannel(channelName); - - await expect(page).toHaveURL(`/group/${channelName}`); + // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format + test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { + await page.goto('/home'); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + const channelName = faker.string.uuid(); - const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); - // send old format encrypted message via API - const msg = await page.evaluate(async (rid) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); - const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); - }, rid); + await expect(page).toHaveURL(`/group/${channelName}`); - await request.post(`${BASE_API_URL}/chat.sendMessage`, { - headers: { - 'X-Auth-Token': Users.userE2EE.data.loginToken, - 'X-User-Id': Users.userE2EE.data._id, - }, - data: { - message: { - rid, - msg, - t: 'e2e', - }, + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + // TODO: Fix this flakiness + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + + const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); + + // send old format encrypted message via API + const msg = await page.evaluate(async (rid) => { + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); + const e2eRoom = await e2e.getInstanceByRoomId(rid); + return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); + }, rid); + + await request.post(`${BASE_API_URL}/chat.sendMessage`, { + headers: { + 'X-Auth-Token': Users.userE2EE.data.loginToken, + 'X-User-Id': Users.userE2EE.data._id, + }, + data: { + message: { + rid, + msg, + t: 'e2e', }, - }); - - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }, }); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message', { timeout: 10000 }); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); }); From b664ead19f9b32c3082b895e5cf28cec3b603bd5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 14:54:17 -0300 Subject: [PATCH 030/251] load keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 41 ++++++++----------- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 2 +- packages/e2ee-web/src/index.ts | 12 ++++-- packages/e2ee/src/index.ts | 18 ++++++++ 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index ddf2547835dca..e0e7647143381 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -5,7 +5,7 @@ import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUs import { isE2EEMessage } from '@rocket.chat/core-typings'; import type { KeyPair } from '@rocket.chat/e2ee'; import type { Optional } from '@rocket.chat/e2ee/dist/utils'; -import E2EE from '@rocket.chat/e2ee-web'; +import E2EE, { LocalStorage } from '@rocket.chat/e2ee-web'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -63,17 +63,10 @@ class E2E extends Emitter { this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = new E2EE( - { - load: (keyName) => Promise.resolve(Accounts.storageLocation.getItem(keyName)), - store: (keyName, value) => Promise.resolve(Accounts.storageLocation.setItem(keyName, value)), - remove: (keyName) => Promise.resolve(Accounts.storageLocation.removeItem(keyName)), - }, - { - userId: () => Promise.resolve(Meteor.userId()), - fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), - }, - ); + this.e2ee = new E2EE(new LocalStorage(Accounts.storageLocation), { + userId: () => Promise.resolve(Meteor.userId()), + fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), + }); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); @@ -454,18 +447,18 @@ class E2E extends Emitter { } } - async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { - Accounts.storageLocation.setItem('public_key', public_key); - this.publicKey = public_key; - - try { - this.privateKey = await this.e2ee.codec.crypto.importRsaDecryptKey(EJSON.parse(private_key)); - - Accounts.storageLocation.setItem('private_key', private_key); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error importing private key: ', error); - } + async loadKeys(keys: { public_key: string; private_key: string }): Promise { + this.publicKey = keys.public_key; + await this.e2ee.loadKeys(keys, { + onSuccess: (privateKey) => { + this.privateKey = privateKey; + }, + onError: (error) => { + this.setState(E2EEState.ERROR); + this.error('Error loading keys: ', error); + }, + parse: (data) => EJSON.parse(data), + }); } async createAndLoadKeys(): Promise { diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 9aa735d2233ad..7dedd05c6cf4d 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -914,7 +914,7 @@ test.describe.serial('e2e-encryption', () => { test.use({ storageState: Users.admin.state }); -test.describe.serial('e2ee room setup', () => { +test.describe.fixme('e2ee room setup', () => { let poAccountProfile: AccountProfile; let poHomeChannel: HomeChannel; let e2eePassword: string; diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts index 2fa8994c33ff0..0cd81cd3457a9 100644 --- a/packages/e2ee-web/src/index.ts +++ b/packages/e2ee-web/src/index.ts @@ -17,16 +17,20 @@ class MemoryStorage implements KeyStorage { } } -class LocalStorage implements KeyStorage { +export class LocalStorage implements KeyStorage { + #storage: Storage; + constructor(storage: Storage = globalThis.localStorage) { + this.#storage = storage; + } load(keyName: string): Promise { - return Promise.resolve(localStorage.getItem(keyName)); + return Promise.resolve(this.#storage.getItem(keyName)); } store(keyName: string, value: string): Promise { - localStorage.setItem(keyName, value); + this.#storage.setItem(keyName, value); return Promise.resolve(); } remove(keyName: string): Promise { - localStorage.removeItem(keyName); + this.#storage.removeItem(keyName); return Promise.resolve(); } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index cc5ddc391d339..080f77d568277 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -37,6 +37,24 @@ export abstract class BaseE2EE { this.#codec = codec; } + async loadKeys( + { public_key, private_key }: { public_key: string; private_key: string }, + callbacks: { + onSuccess: (privateKey: CryptoKey) => void; + onError: (error: unknown) => void; + parse: (data: string) => object; + }, + ): Promise { + await this.#storage.store('public_key', public_key); + try { + callbacks.onSuccess(await this.codec.crypto.importRsaDecryptKey(callbacks.parse(private_key))); + + await this.#storage.store('private_key', private_key); + } catch (error) { + callbacks.onError(error); + } + } + async setPublicKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); From b1a96ce502d0139b55bdd5d7e807a3cbdc3170d0 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 15:21:35 -0300 Subject: [PATCH 031/251] create and load keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 37 +++++++------------ packages/e2ee/src/index.ts | 29 +++++++++++++++ 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index e0e7647143381..d28a645ae7b93 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -464,30 +464,21 @@ class E2E extends Emitter { async createAndLoadKeys(): Promise { // Could not obtain public-private keypair from server. this.setState(E2EEState.LOADING_KEYS); - let key; - try { - key = await this.e2ee.codec.crypto.generateRsaOaepKeyPair(); - this.privateKey = key.privateKey; - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error generating key: ', error); - } - - try { - const publicKey = await this.e2ee.codec.crypto.exportJsonWebKey(key.publicKey); - - this.publicKey = JSON.stringify(publicKey); - await this.e2ee.setPublicKey(key.publicKey); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error exporting public key: ', error); - } + await this.e2ee.createAndLoadKeys({ + onPrivateKey: (privateKey) => { + this.privateKey = privateKey; + }, + onPublicKey: (publicKey) => { + this.publicKey = JSON.stringify(publicKey); + }, + onError: (error) => { + this.setState(E2EEState.ERROR); + this.error('Error creating keys: ', error); + }, + }); - try { - await this.e2ee.setPrivateKey(key.privateKey); - } catch (error) { - this.setState(E2EEState.ERROR); - return this.error('Error exporting private key: ', error); + if (this.getState() === E2EEState.LOADING_KEYS) { + return; } await this.requestSubscriptionKeys(); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 080f77d568277..6a368e2c46560 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -55,6 +55,35 @@ export abstract class BaseE2EE { } } + async createAndLoadKeys(callbacks: { + onPrivateKey: (privateKey: CryptoKey) => void; + onPublicKey: (publicKey: JsonWebKey) => void; + onError: (error: unknown) => void; + }): Promise { + // Could not obtain public-private keypair from server. + try { + const keys = await this.#codec.crypto.generateRsaOaepKeyPair(); + callbacks.onPrivateKey(keys.privateKey); + try { + await this.setPublicKey(keys.publicKey); + const publicKey = await this.#codec.crypto.exportJsonWebKey(keys.publicKey); + callbacks.onPublicKey(publicKey); + try { + await this.setPrivateKey(keys.privateKey); + } catch (error) { + callbacks.onError(error); + return; + } + } catch (error) { + callbacks.onError(error); + return; + } + } catch (error) { + callbacks.onError(error); + return; + } + } + async setPublicKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); From ff461124715ed7b4dca7c7f3b0a45e7b0d9db5f5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 15:26:06 -0300 Subject: [PATCH 032/251] preserve behavior --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index d28a645ae7b93..84900de94d373 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -477,7 +477,7 @@ class E2E extends Emitter { }, }); - if (this.getState() === E2EEState.LOADING_KEYS) { + if (this.getState() === E2EEState.ERROR) { return; } From b30ed790e183f1236cced2f72024774bb2fb0741 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 16:05:40 -0300 Subject: [PATCH 033/251] load keys from db --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 27 ++++++++++--------- packages/e2ee/src/index.ts | 17 ++++++++++++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 84900de94d373..adce80a331a68 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -432,19 +432,20 @@ class E2E extends Emitter { } async loadKeysFromDB(): Promise { - try { - this.setState(E2EEState.LOADING_KEYS); - const { public_key, private_key } = await this.e2ee.getKeysFromService(); - - this.db_public_key = public_key; - this.db_private_key = private_key; - } catch (error) { - this.setState(E2EEState.ERROR); - this.error('Error fetching RSA keys: ', error); - // Stop any process since we can't communicate with the server - // to get the keys. This prevents new key generation - throw error; - } + this.setState(E2EEState.LOADING_KEYS); + await this.e2ee.loadKeysFromDB({ + onSuccess: ({ public_key, private_key }) => { + this.db_public_key = public_key; + this.db_private_key = private_key; + }, + onError: (error) => { + this.setState(E2EEState.ERROR); + this.error('Error fetching RSA keys from DB: ', error); + // Stop any process since we can't communicate with the server + // to get the keys. This prevents new key generation + throw error; + }, + }); } async loadKeys(keys: { public_key: string; private_key: string }): Promise { diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 6a368e2c46560..58c26119b2a7e 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -84,6 +84,23 @@ export abstract class BaseE2EE { } } + async loadKeysFromDB(callbacks: { + onSuccess: (keys: KeyPair) => void; + onError: (error: unknown) => void; + }): Promise { + try { + callbacks.onSuccess((await this.getKeysFromService())); + } catch (error) { + callbacks.onError(error); + } + } + + /// Low-level key management methods + + /** + * Sets the public key in the storage. + * @param key The public key to store. + */ async setPublicKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); From d2a6a80ec2c8b3a9c767eeafd0ac80d315a0733d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 16:27:41 -0300 Subject: [PATCH 034/251] E2EEState d-enum-ify --- .../roomActions/useE2EERoomAction.spec.ts | 3 +- .../hooks/roomActions/useE2EERoomAction.ts | 3 +- apps/meteor/client/lib/e2ee/E2EEState.ts | 10 +-- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 67 +++++++++++-------- .../views/room/E2EESetup/RoomE2EESetup.tsx | 5 +- .../views/room/Header/RoomHeaderE2EESetup.tsx | 3 +- .../room/HeaderV2/RoomHeaderE2EESetup.tsx | 3 +- .../EditRoomInfo/useEditRoomPermissions.ts | 3 +- .../root/hooks/loggedIn/useE2EEncryption.ts | 5 +- 9 files changed, 48 insertions(+), 54 deletions(-) diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts index 533d9c1112bfc..616897c621739 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.spec.ts @@ -3,7 +3,6 @@ import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts import { act, renderHook, waitFor } from '@testing-library/react'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { E2EEState } from '../../lib/e2ee/E2EEState'; import { e2e } from '../../lib/e2ee/rocketchat.e2e'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import { useE2EEState } from '../../views/room/hooks/useE2EEState'; @@ -70,7 +69,7 @@ describe('useE2EERoomAction', () => { (useSetting as jest.Mock).mockReturnValue(true); (useRoom as jest.Mock).mockReturnValue(mockRoom); (useRoomSubscription as jest.Mock).mockReturnValue(mockSubscription); - (useE2EEState as jest.Mock).mockReturnValue(E2EEState.READY); + (useE2EEState as jest.Mock).mockReturnValue('READY'); (usePermission as jest.Mock).mockReturnValue(true); (useEndpoint as jest.Mock).mockReturnValue(jest.fn().mockResolvedValue({ success: true })); (e2e.isReady as jest.Mock).mockReturnValue(true); diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index fafe63bd88c91..346787523f9b6 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -6,7 +6,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { E2EEState } from '../../lib/e2ee/E2EEState'; import { E2ERoomState } from '../../lib/e2ee/E2ERoomState'; import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; @@ -23,7 +22,7 @@ export const useE2EERoomAction = () => { const subscription = useRoomSubscription(); const e2eeState = useE2EEState(); const e2eeRoomState = useE2EERoomState(room._id); - const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD; + const isE2EEReady = e2eeState === 'READY' || e2eeState === 'SAVE_PASSWORD'; const readyToEncrypt = isE2EEReady || room.encrypted; const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id); const permittedToEditRoom = usePermission('edit-room', room._id); diff --git a/apps/meteor/client/lib/e2ee/E2EEState.ts b/apps/meteor/client/lib/e2ee/E2EEState.ts index 0e505ec4a1bd3..68a5222939adc 100644 --- a/apps/meteor/client/lib/e2ee/E2EEState.ts +++ b/apps/meteor/client/lib/e2ee/E2EEState.ts @@ -1,9 +1 @@ -export enum E2EEState { - NOT_STARTED = 'NOT_STARTED', - DISABLED = 'DISABLED', - LOADING_KEYS = 'LOADING_KEYS', - READY = 'READY', - SAVE_PASSWORD = 'SAVE_PASSWORD', - ENTER_PASSWORD = 'ENTER_PASSWORD', - ERROR = 'ERROR', -} +export type E2EEState = 'NOT_STARTED' | 'DISABLED' | 'LOADING_KEYS' | 'READY' | 'SAVE_PASSWORD' | 'ENTER_PASSWORD' | 'ERROR'; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index adce80a331a68..49d7830aed73e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -13,7 +13,7 @@ import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { E2EEState } from './E2EEState'; +import type { E2EEState } from './E2EEState'; import { toString, toArrayBuffer, joinVectorAndEcryptedData, splitVectorAndEcryptedData } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -39,7 +39,16 @@ const ROOM_KEY_EXCHANGE_SIZE = 10; // -class E2E extends Emitter { +class E2E extends Emitter<{ + READY: void; + E2E_STATE_CHANGED: { prevState: E2EEState; nextState: E2EEState }; + SAVE_PASSWORD: void; + DISABLED: void; + NOT_STARTED: void; + ERROR: void; + LOADING_KEYS: void; + ENTER_PASSWORD: void; +}> { private started: boolean; private instancesByRoomId: Record; @@ -72,27 +81,27 @@ class E2E extends Emitter { this.log(`${prevState} -> ${nextState}`); }); - this.on(E2EEState.READY, async () => { + this.on('READY', async () => { await this.onE2EEReady(); }); - this.on(E2EEState.SAVE_PASSWORD, async () => { + this.on('SAVE_PASSWORD', async () => { await this.onE2EEReady(); }); - this.on(E2EEState.DISABLED, () => { + this.on('DISABLED', () => { this.unsubscribeFromSubscriptions?.(); }); - this.on(E2EEState.NOT_STARTED, () => { + this.on('NOT_STARTED', () => { this.unsubscribeFromSubscriptions?.(); }); - this.on(E2EEState.ERROR, () => { + this.on('ERROR', () => { this.unsubscribeFromSubscriptions?.(); }); - this.setState(E2EEState.NOT_STARTED); + this.setState('NOT_STARTED'); } log(...msg: unknown[]) { @@ -108,12 +117,12 @@ class E2E extends Emitter { } isEnabled(): boolean { - return this.state !== E2EEState.DISABLED; + return this.state !== 'DISABLED'; } isReady(): boolean { // Save_Password state is also a ready state for E2EE - return this.state === E2EEState.READY || this.state === E2EEState.SAVE_PASSWORD; + return this.state === 'READY' || this.state === 'SAVE_PASSWORD'; } async onE2EEReady() { @@ -334,7 +343,7 @@ class E2E extends Emitter { }, onConfirm: () => { void this.e2ee.removeRandomPassword(); - this.setState(E2EEState.READY); + this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); this.closeAlert(); imperativeModal.close(); @@ -362,7 +371,7 @@ class E2E extends Emitter { if (await this.shouldAskForE2EEPassword()) { try { - this.setState(E2EEState.ENTER_PASSWORD); + this.setState('ENTER_PASSWORD'); private_key = await this.decodePrivateKey(this.db_private_key as string); } catch { this.started = false; @@ -384,20 +393,20 @@ class E2E extends Emitter { if (public_key && private_key) { await this.loadKeys({ public_key, private_key }); - this.setState(E2EEState.READY); + this.setState('READY'); } else { await this.createAndLoadKeys(); - this.setState(E2EEState.READY); + this.setState('READY'); } if (!this.db_public_key || !this.db_private_key) { - this.setState(E2EEState.LOADING_KEYS); + this.setState('LOADING_KEYS'); await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), await this.e2ee.createRandomPassword(5)); } const randomPassword = await this.e2ee.getRandomPassword(); if (randomPassword) { - this.setState(E2EEState.SAVE_PASSWORD); + this.setState('SAVE_PASSWORD'); this.openAlert({ title: () => t('Save_your_encryption_password'), html: () => t('Click_here_to_view_and_copy_your_password'), @@ -420,7 +429,7 @@ class E2E extends Emitter { this.started = false; this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); this.keyDistributionInterval = null; - this.setState(E2EEState.DISABLED); + this.setState('DISABLED'); } async changePassword(newPassword: string): Promise { @@ -432,14 +441,14 @@ class E2E extends Emitter { } async loadKeysFromDB(): Promise { - this.setState(E2EEState.LOADING_KEYS); + this.setState('LOADING_KEYS'); await this.e2ee.loadKeysFromDB({ onSuccess: ({ public_key, private_key }) => { this.db_public_key = public_key; this.db_private_key = private_key; }, onError: (error) => { - this.setState(E2EEState.ERROR); + this.setState('ERROR'); this.error('Error fetching RSA keys from DB: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation @@ -455,7 +464,7 @@ class E2E extends Emitter { this.privateKey = privateKey; }, onError: (error) => { - this.setState(E2EEState.ERROR); + this.setState('ERROR'); this.error('Error loading keys: ', error); }, parse: (data) => EJSON.parse(data), @@ -464,7 +473,7 @@ class E2E extends Emitter { async createAndLoadKeys(): Promise { // Could not obtain public-private keypair from server. - this.setState(E2EEState.LOADING_KEYS); + this.setState('LOADING_KEYS'); await this.e2ee.createAndLoadKeys({ onPrivateKey: (privateKey) => { this.privateKey = privateKey; @@ -473,12 +482,12 @@ class E2E extends Emitter { this.publicKey = JSON.stringify(publicKey); }, onError: (error) => { - this.setState(E2EEState.ERROR); + this.setState('ERROR'); this.error('Error creating keys: ', error); }, }); - if (this.getState() === E2EEState.ERROR) { + if (this.getState() === 'ERROR') { return; } @@ -500,7 +509,7 @@ class E2E extends Emitter { return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey)); } catch (error) { - this.setState(E2EEState.ERROR); + this.setState('ERROR'); return this.error('Error encrypting encodedPrivateKey: ', error); } } @@ -512,7 +521,7 @@ class E2E extends Emitter { return res.value; } - this.setState(E2EEState.ERROR); + this.setState('ERROR'); return this.error('Error getting master key: ', res.error); } @@ -584,14 +593,14 @@ class E2E extends Emitter { if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); - this.setState(E2EEState.READY); + this.setState('READY'); } else { await this.createAndLoadKeys(); - this.setState(E2EEState.READY); + this.setState('READY'); } dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); } catch { - this.setState(E2EEState.ENTER_PASSWORD); + this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); throw new Error('E2E -> Error decrypting private key'); @@ -612,7 +621,7 @@ class E2E extends Emitter { const privKey = await this.e2ee.codec.crypto.decryptAesCbc(masterKey, vector, cipherText); return toString(privKey); } catch { - this.setState(E2EEState.ENTER_PASSWORD); + this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); throw new Error('E2E -> Error decrypting private key'); diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index edfac2b0d0542..70b3b3a550af8 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'; import RoomE2EENotAllowed from './RoomE2EENotAllowed'; import { e2e } from '../../../lib/e2ee'; -import { E2EEState } from '../../../lib/e2ee/E2EEState'; import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import RoomBody from '../body/RoomBody'; import RoomBodyV2 from '../body/RoomBodyV2'; @@ -32,7 +31,7 @@ const RoomE2EESetup = () => { const onEnterE2EEPassword = useCallback(() => e2e.decodePrivateKeyFlow(), []); - if (e2eeState === E2EEState.SAVE_PASSWORD) { + if (e2eeState === 'SAVE_PASSWORD') { return ( { ); } - if (e2eeState === E2EEState.ENTER_PASSWORD) { + if (e2eeState === 'ENTER_PASSWORD') { return ( { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === E2ERoomState.WAITING_KEYS) { return } />; } diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx index 20104d7625c48..3b9f265e838d2 100644 --- a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx @@ -2,7 +2,6 @@ import { lazy } from 'react'; import RoomHeader from './RoomHeader'; import type { RoomHeaderProps } from './RoomHeader'; -import { E2EEState } from '../../../lib/e2ee/E2EEState'; import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import { useE2EERoomState } from '../hooks/useE2EERoomState'; import { useE2EEState } from '../hooks/useE2EEState'; @@ -13,7 +12,7 @@ const RoomHeaderE2EESetup = ({ room }: RoomHeaderProps) => { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === E2EEState.SAVE_PASSWORD || e2eeState === E2EEState.ENTER_PASSWORD || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === E2ERoomState.WAITING_KEYS) { return } />; } diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts index c9f6cf1a40e2c..9f88c4308721f 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts @@ -4,7 +4,6 @@ import { useMemo } from 'react'; import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig'; import { useTeamInfoQuery } from '../../../../../hooks/useTeamInfoQuery'; -import { E2EEState } from '../../../../../lib/e2ee/E2EEState'; import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; import { useE2EEState } from '../../../hooks/useE2EEState'; @@ -14,7 +13,7 @@ const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChann export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) => { const isAdmin = useRole('admin'); const e2eeState = useE2EEState(); - const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD; + const isE2EEReady = e2eeState === 'READY' || e2eeState === 'SAVE_PASSWORD'; const canCreateChannel = usePermission('create-c'); const canCreateGroup = usePermission('create-p'); diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 849abc9c5a20c..5d03f9f10aeb9 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -4,7 +4,6 @@ import { useEffect, useRef } from 'react'; import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser'; import { e2e } from '../../../../lib/e2ee'; -import { E2EEState } from '../../../../lib/e2ee/E2EEState'; import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived'; import { Rooms } from '../../../../stores'; @@ -33,13 +32,13 @@ export const useE2EEncryption = () => { e2e.startClient(); } else { e2e.log('E2E disabled'); - e2e.setState(E2EEState.DISABLED); + e2e.setState('DISABLED'); e2e.closeAlert(); } }, [adminEmbedded, enabled, userId]); const state = useE2EEState(); - const ready = state === E2EEState.READY || state === E2EEState.SAVE_PASSWORD; + const ready = state === 'READY' || state === 'SAVE_PASSWORD'; const listenersAttachedRef = useRef(false); const mentionsEnabled = useSetting('E2E_Enabled_Mentions', true); From 66c6e95842840587242cf82a943441a770a920b2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 20 Aug 2025 17:43:47 -0300 Subject: [PATCH 035/251] parse & stringify Uint8Array --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 12 +- .../e2ee-node/src/__tests__/codec.test.ts | 23 +- packages/e2ee-web/src/__tests__/codec.test.ts | 18 +- packages/e2ee/package.json | 3 +- packages/e2ee/src/codec.ts | 144 +---------- packages/e2ee/src/crypto.ts | 131 ++++++++++ yarn.lock | 243 +++++++++++++++++- 7 files changed, 421 insertions(+), 153 deletions(-) create mode 100644 packages/e2ee/src/crypto.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 49d7830aed73e..5150356881f2f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -499,15 +499,15 @@ class E2E extends Emitter<{ } async encodePrivateKey(privateKey: string, password: string): Promise { - const masterKey = await this.getMasterKey(password); + const masterKey = await this.e2ee.getMasterKey(password); const vector = this.e2ee.codec.getRandomUint8Array(16); try { - if (!masterKey) { + if (!masterKey.isOk) { throw new Error('Error getting master key'); } - const encodedPrivateKey = await this.e2ee.codec.crypto.encryptAesCbc(masterKey, vector, toArrayBuffer(privateKey)); + const encodedPrivateKey = await this.e2ee.codec.crypto.encryptAesCbc(masterKey.value, vector, toArrayBuffer(privateKey)); - return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey)); + return this.e2ee.codec.stringifyUint8Array(joinVectorAndEcryptedData(vector, encodedPrivateKey)); } catch (error) { this.setState('ERROR'); return this.error('Error encrypting encodedPrivateKey: ', error); @@ -582,7 +582,7 @@ class E2E extends Emitter<{ return; } - const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(this.db_private_key)); + const [vector, cipherText] = splitVectorAndEcryptedData(this.e2ee.codec.parseUint8Array(this.db_private_key)); try { if (!masterKey) { @@ -612,7 +612,7 @@ class E2E extends Emitter<{ const masterKey = await this.getMasterKey(password); - const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(privateKey)); + const [vector, cipherText] = splitVectorAndEcryptedData(this.e2ee.codec.parseUint8Array(privateKey)); try { if (!masterKey) { diff --git a/packages/e2ee-node/src/__tests__/codec.test.ts b/packages/e2ee-node/src/__tests__/codec.test.ts index 5c97e3655a3a4..e856df57b80f3 100644 --- a/packages/e2ee-node/src/__tests__/codec.test.ts +++ b/packages/e2ee-node/src/__tests__/codec.test.ts @@ -1,11 +1,24 @@ import { test, expect } from 'vitest'; import KeyCodec from '../codec.ts'; +import type { JsonWebKey } from 'node:crypto'; -const createKeyCodec = () => new KeyCodec(); +const codec = new KeyCodec(); + +test('KeyCodec stringifyUint8Array', () => { + const data = new Uint8Array([1, 2, 3]); + const jsonString = codec.stringifyUint8Array(data); + expect(jsonString).toBe('{"$binary":"AQID"}'); +}); + +test('KeyCodec parseUint8Array', () => { + const jsonString = '{"$binary":"AQID"}'; + const data = codec.parseUint8Array(jsonString); + expect(data).toEqual(new Uint8Array([1, 2, 3])); +}); test('KeyCodec roundtrip (v1 structured encoding)', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); + const saltBuffer = codec.encodeSalt('salt-user-1'); const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); @@ -14,7 +27,6 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { }); test('KeyCodec wrong password fails', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt1 = codec.encodeSalt('salt1'); const masterKey = await codec.deriveMasterKey(salt1, 'correct'); @@ -24,9 +36,7 @@ test('KeyCodec wrong password fails', async () => { }); test('KeyCodec tamper detection (ciphertext)', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); - // const privStr = JSON.stringify(privateJWK); const salt = codec.encodeSalt('salt'); const masterKey = await codec.deriveMasterKey(salt, 'pw'); const enc = await codec.encodePrivateKey(privateJWK, masterKey); @@ -36,11 +46,10 @@ test('KeyCodec tamper detection (ciphertext)', async () => { }); test('KeyCodec legacy roundtrip', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); - const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as any) : decAny; + const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as JsonWebKey) : decAny; expect(dec).toStrictEqual(privateJWK); }); diff --git a/packages/e2ee-web/src/__tests__/codec.test.ts b/packages/e2ee-web/src/__tests__/codec.test.ts index f0ce901af9d76..84b3c0aa559c3 100644 --- a/packages/e2ee-web/src/__tests__/codec.test.ts +++ b/packages/e2ee-web/src/__tests__/codec.test.ts @@ -1,10 +1,21 @@ import { test, expect } from 'vitest'; import KeyCodec from '../codec.ts'; -const createKeyCodec = () => new KeyCodec(); +const codec = new KeyCodec(); + +test('KeyCodec stringifyUint8Array', () => { + const data = new Uint8Array([1, 2, 3]); + const jsonString = codec.stringifyUint8Array(data); + expect(jsonString).toBe('{"$binary":"AQID"}'); +}); + +test('KeyCodec parseUint8Array', () => { + const jsonString = '{"$binary":"AQID"}'; + const data = codec.parseUint8Array(jsonString); + expect(data).toEqual(new Uint8Array([1, 2, 3])); +}); test('KeyCodec roundtrip (v1 structured encoding)', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const saltBuffer = codec.encodeSalt('salt-user-1'); @@ -15,7 +26,6 @@ test('KeyCodec roundtrip (v1 structured encoding)', async () => { }); test('KeyCodec wrong password fails', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt1 = codec.encodeSalt('salt1'); const masterKey = await codec.deriveMasterKey(salt1, 'correct'); @@ -25,7 +35,6 @@ test('KeyCodec wrong password fails', async () => { }); test('KeyCodec tamper detection (ciphertext)', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const salt = codec.encodeSalt('salt'); const masterKey = await codec.deriveMasterKey(salt, 'pw'); @@ -36,7 +45,6 @@ test('KeyCodec tamper detection (ciphertext)', async () => { }); test('KeyCodec legacy roundtrip', async () => { - const codec = createKeyCodec(); const { privateJWK } = await codec.generateRsaOaepKeyPair(); const privStr = JSON.stringify(privateJWK); const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 35e896d8bfe55..efba6fc5451c0 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -28,7 +28,8 @@ "@noble/hashes": "~1.8.0", "oxlint": "~1.12.0", "oxlint-tsgolint": "~0.0.4", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "" }, "license": "MIT", "publishConfig": { diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 7d6ab8d73740f..29f9b20e771b3 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -1,134 +1,4 @@ -export interface CryptoProvider { - /** - * @example - * (key) => await crypto.subtle.exportKey('jwk', key) - */ - exportJsonWebKey(key: CryptoKey): Promise; - /** - * @example - * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']) - */ - importRawKey(raw: Uint8Array): Promise; - /** - * @example - * (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']) - */ - importRsaDecryptKey(key: JsonWebKey): Promise; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint8Array(array: Uint8Array): Uint8Array; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint16Array(array: Uint16Array): Uint16Array; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint32Array(array: Uint32Array): Uint32Array; - /** - * @example - * () => crypto.subtle.generateKey( - * { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - * true, - * ['encrypt', 'decrypt'], - * ) - */ - generateRsaOaepKeyPair(): Promise; - /** - * @example - * (input) => { - * const binaryString = atob(input); - * const len = binaryString.length; - * const bytes = new Uint8Array(len); - * for (let i = 0; i < len; i++) { - * bytes[i] = binaryString.charCodeAt(i); - * } - * return bytes; - * } - */ - decodeBase64(input: string): Uint8Array; - /** - * @example - * (input) => { - * const bytes = new Uint8Array(input); - * let binaryString = ''; - * for (let i = 0; i < bytes.byteLength; i++) { - * binaryString += String.fromCharCode(bytes[i]!); - * } - * return btoa(binaryString); - * } - */ - encodeBase64(input: ArrayBuffer): string; - /** - * @example - * NodeJS / Web: - * ``` - * (text: string): Uint8Array => { - * const encoder = new TextEncoder(); - * const buffer = new Uint8Array(); - * encoder.encodeInto(text, buffer); - * return buffer; - * } - * ``` - */ - encodeBinary(input: string): Uint8Array; - /** - * @example - * NodeJS / Web: - * ``` - * (input) => new TextEncoder().encode(input) - * ``` - */ - encodeUtf8(input: string): Uint8Array; - /** - * @example - * NodeJS / Web: - * ``` - * (input) => TextDecoder().decode(input) - * ``` - */ - decodeUtf8(input: ArrayBuffer): string; - /** - * @example - * NodeJS / Web: - * ``` - * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data) - * ``` - */ - decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; - /** - * @example - * NodeJS / Web: - * ``` - * (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data) - * ```` - */ - encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; - /** - * @example - * NodeJS / Web: - * (key, iv, data) => crypto.subtle.deriveKey( - * { - * name: 'PBKDF2', - * salt, - * iterations: 1000, - * hash: 'SHA-256', - * }, - * baseKey, - * { - * name: 'AES-CBC', - * length: 256, - * }, - * false, - * ['encrypt', 'decrypt'] - * ) - */ - deriveKeyWithPbkdf2(salt: ArrayBuffer, baseKey: CryptoKey): Promise; -} +import type { CryptoProvider } from './crypto.ts'; export interface JsonWebKeyPair { privateJWK: JsonWebKey; @@ -291,4 +161,16 @@ export abstract class BaseKeyCodec { const randomBuffer = this.getRandomUint32Array(length); return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); } + + stringifyUint8Array(data: Uint8Array): string { + return JSON.stringify({ + $binary: this.#crypto.encodeBase64(data.buffer), + }); + } + + parseUint8Array(json: string): Uint8Array { + const { $binary } = JSON.parse(json); + const binaryString = this.#crypto.decodeBase64($binary); + return new Uint8Array(binaryString); + } } diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/crypto.ts new file mode 100644 index 0000000000000..303333786e6f8 --- /dev/null +++ b/packages/e2ee/src/crypto.ts @@ -0,0 +1,131 @@ +export interface CryptoProvider { + /** + * @example + * (key) => await crypto.subtle.exportKey('jwk', key) + */ + exportJsonWebKey(key: CryptoKey): Promise; + /** + * @example + * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']) + */ + importRawKey(raw: Uint8Array): Promise; + /** + * @example + * (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']) + */ + importRsaDecryptKey(key: JsonWebKey): Promise; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint8Array(array: Uint8Array): Uint8Array; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint16Array(array: Uint16Array): Uint16Array; + /** + * @example + * (array) => crypto.getRandomValues(array), + */ + getRandomUint32Array(array: Uint32Array): Uint32Array; + /** + * @example + * () => crypto.subtle.generateKey( + * { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + * true, + * ['encrypt', 'decrypt'], + * ) + */ + generateRsaOaepKeyPair(): Promise; + /** + * @example + * (input) => { + * const binaryString = atob(input); + * const len = binaryString.length; + * const bytes = new Uint8Array(len); + * for (let i = 0; i < len; i++) { + * bytes[i] = binaryString.charCodeAt(i); + * } + * return bytes; + * } + */ + decodeBase64(input: string): Uint8Array; + /** + * @example + * (input) => { + * const bytes = new Uint8Array(input); + * let binaryString = ''; + * for (let i = 0; i < bytes.byteLength; i++) { + * binaryString += String.fromCharCode(bytes[i]!); + * } + * return btoa(binaryString); + * } + */ + encodeBase64(input: ArrayBuffer): string; + /** + * @example + * NodeJS / Web: + * ``` + * (text: string): Uint8Array => { + * const encoder = new TextEncoder(); + * const buffer = new Uint8Array(); + * encoder.encodeInto(text, buffer); + * return buffer; + * } + * ``` + */ + encodeBinary(input: string): Uint8Array; + /** + * @example + * NodeJS / Web: + * ``` + * (input) => new TextEncoder().encode(input) + * ``` + */ + encodeUtf8(input: string): Uint8Array; + /** + * @example + * NodeJS / Web: + * ``` + * (input) => TextDecoder().decode(input) + * ``` + */ + decodeUtf8(input: ArrayBuffer): string; + /** + * @example + * NodeJS / Web: + * ``` + * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data) + * ``` + */ + decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; + /** + * @example + * NodeJS / Web: + * ``` + * (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data) + * ```` + */ + encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; + /** + * @example + * NodeJS / Web: + * (key, iv, data) => crypto.subtle.deriveKey( + * { + * name: 'PBKDF2', + * salt, + * iterations: 1000, + * hash: 'SHA-256', + * }, + * baseKey, + * { + * name: 'AES-CBC', + * length: 256, + * }, + * false, + * ['encrypt', 'decrypt'] + * ) + */ + deriveKeyWithPbkdf2(salt: ArrayBuffer, baseKey: CryptoKey): Promise; +} diff --git a/yarn.lock b/yarn.lock index 69a09b48124c5..3e2e77ed49329 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7446,6 +7446,7 @@ __metadata: oxlint: "npm:~1.12.0" oxlint-tsgolint: "npm:~0.0.4" typescript: "npm:~5.9.2" + vitest: "npm:" languageName: unknown linkType: soft @@ -13469,6 +13470,19 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/expect@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10/dc69ce886c13714dfbbff78f2d2cb7eb536017e82301a73c42d573a9e9d2bf91005ac7abd9b977adf0a3bd431209f45a8ac2418029b68b0a377e092607c843ce + languageName: node + linkType: hard + "@vitest/expect@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/expect@npm:4.0.0-beta.8" @@ -13482,6 +13496,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/mocker@npm:3.2.4" + dependencies: + "@vitest/spy": "npm:3.2.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10/5e92431b6ed9fc1679060e4caef3e4623f4750542a5d7cd944774f8217c4d231e273202e8aea00bab33260a5a9222ecb7005d80da0348c3c829bd37d123071a8 + languageName: node + linkType: hard + "@vitest/mocker@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/mocker@npm:4.0.0-beta.8" @@ -13519,6 +13552,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": + version: 3.2.4 + resolution: "@vitest/pretty-format@npm:3.2.4" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10/8dd30cbf956e01fbab042fe651fb5175d9f0cd00b7b569a46cd98df89c4fec47dab12916201ad6e09a4f25f2a2ec8927a4bfdc61118593097f759c90b18a51d4 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:4.0.0-beta.8, @vitest/pretty-format@npm:^4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/pretty-format@npm:4.0.0-beta.8" @@ -13528,6 +13570,17 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/runner@npm:3.2.4" + dependencies: + "@vitest/utils": "npm:3.2.4" + pathe: "npm:^2.0.3" + strip-literal: "npm:^3.0.0" + checksum: 10/197bd55def519ef202f990b7c1618c212380831827c116240871033e4973decb780503c705ba9245a12bd8121f3ac4086ffcb3e302148b62d9bd77fd18dd1deb + languageName: node + linkType: hard + "@vitest/runner@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/runner@npm:4.0.0-beta.8" @@ -13539,6 +13592,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/snapshot@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10/acfb682491b9ca9345bf9fed02c2779dec43e0455a380c1966b0aad8dd81c79960902cf34621ab48fe80a0eaf8c61cc42dec186a1321dc3c9897ef2ebd5f1bc4 + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/snapshot@npm:4.0.0-beta.8" @@ -13559,6 +13623,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/spy@npm:3.2.4" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10/7d38c299f42a8c7e5e41652b203af98ca54e63df69c3b072d0e401d5a57fbbba3e39d8538ac1b3022c26718a6388d0bcc222bc2f07faab75942543b9247c007d + languageName: node + linkType: hard + "@vitest/spy@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/spy@npm:4.0.0-beta.8" @@ -13578,6 +13651,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/utils@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10/7f12ef63bd8ee13957744d1f336b0405f164ade4358bf9dfa531f75bbb58ffac02bf61aba65724311ddbc50b12ba54853a169e59c6b837c16086173b9a480710 + languageName: node + linkType: hard + "@vitest/utils@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/utils@npm:4.0.0-beta.8" @@ -15930,6 +16014,13 @@ __metadata: languageName: node linkType: hard +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10/002769a0fbfc51c062acd2a59df465a2a947916b02ac50b56c69ec6c018ee99ac3e7f4dd7366334ea847f1ecacf4defaa61bcd2ac283db50156ce1f1d8c8ad42 + languageName: node + linkType: hard + "cacache@npm:^15.0.5": version: 15.3.0 resolution: "cacache@npm:15.3.0" @@ -16241,6 +16332,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.2.0": + version: 5.3.1 + resolution: "chai@npm:5.3.1" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10/0d801e3e4584dcbba6dbf415b5ea801dbf069f328560aa4d4f9b016e9a362dd58cb44775798e2130f4ad639385cad85dc486f995e672713369a44fd33702dcbf + languageName: node + linkType: hard + "chai@npm:^5.2.1": version: 5.2.1 resolution: "chai@npm:5.2.1" @@ -20295,7 +20399,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.2": +"expect-type@npm:^1.2.1, expect-type@npm:^1.2.2": version: 1.2.2 resolution: "expect-type@npm:1.2.2" checksum: 10/1703e6e47b575f79d801d87f24c639f4d0af71b327a822e6922d0ccb7eb3f6559abb240b8bd43bab6a477903de4cc322908e194d05132c18f52a217115e8e870 @@ -20602,7 +20706,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4, fdir@npm:^6.4.6": +"fdir@npm:^6.4.4, fdir@npm:^6.4.6, fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" peerDependencies: @@ -26252,7 +26356,7 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.2.0": +"loupe@npm:^3.1.4, loupe@npm:^3.2.0": version: 3.2.0 resolution: "loupe@npm:3.2.0" checksum: 10/80d48e35b014c2ba5886e25a02ee4cc9c1f659b0eca9c4fa8f07051cdc689e0507a763fbe05a63abbd3b7d4640774a722c905d4b1681b4b92c3ba8f87d96fea2 @@ -35176,6 +35280,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^4.0.3": + version: 4.0.3 + resolution: "tinyspy@npm:4.0.3" + checksum: 10/b6a3ed40dd76a2b3c020250cf1401506b456509d1fb9dba0c7b0e644d258dac722843b85c57ccc36c8687db1e7978cb6adcc43e3b71c475910c085b96d41cb53 + languageName: node + linkType: hard + "tlds@npm:1.259.0": version: 1.259.0 resolution: "tlds@npm:1.259.0" @@ -36669,6 +36780,76 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:3.2.4": + version: 3.2.4 + resolution: "vite-node@npm:3.2.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.4.1" + es-module-lexer: "npm:^1.7.0" + pathe: "npm:^2.0.3" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + bin: + vite-node: vite-node.mjs + checksum: 10/343244ecabbab3b6e1a3065dabaeefa269965a7a7c54652d4b7a7207ee82185e887af97268c61755dcb2dd6a6ce5d9e114400cbd694229f38523e935703cc62f + languageName: node + linkType: hard + +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": + version: 7.1.3 + resolution: "vite@npm:7.1.3" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.14" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10/792cba6d5090cc6713a1f72ee43578902df0e231db0b4d313589ec380919d3612a01903615e183ebca6a903f83ee4fbfceb3d07a5443d5698d5d992dbc60a995 + languageName: node + linkType: hard + "vite@npm:^6.0.0 || ^7.0.0-0": version: 7.1.2 resolution: "vite@npm:7.1.2" @@ -36776,6 +36957,62 @@ __metadata: languageName: node linkType: hard +"vitest@npm:": + version: 3.2.4 + resolution: "vitest@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/expect": "npm:3.2.4" + "@vitest/mocker": "npm:3.2.4" + "@vitest/pretty-format": "npm:^3.2.4" + "@vitest/runner": "npm:3.2.4" + "@vitest/snapshot": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + debug: "npm:^4.4.1" + expect-type: "npm:^1.2.1" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.2" + std-env: "npm:^3.9.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.14" + tinypool: "npm:^1.1.1" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node: "npm:3.2.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.4 + "@vitest/ui": 3.2.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10/f10bbce093ecab310ecbe484536ef4496fb9151510b2be0c5907c65f6d31482d9c851f3182531d1d27d558054aa78e8efd9d4702ba6c82058657e8b6a52507ee + languageName: node + linkType: hard + "vitest@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "vitest@npm:4.0.0-beta.8" From 4c8ef18a8fff3bd70a8a68c7831c1d0e34bedd30 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 11:54:12 -0300 Subject: [PATCH 036/251] encode private key --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 19 +- packages/e2ee/package.json | 4 +- packages/e2ee/src/__tests__/.oxlintrc.json | 5 + packages/e2ee/src/__tests__/codec.test.ts | 68 +++-- packages/e2ee/src/__tests__/vector.test.ts | 39 +++ packages/e2ee/src/binary.ts | 38 +++ packages/e2ee/src/index.ts | 25 +- packages/e2ee/src/vector.ts | 23 ++ packages/e2ee/tsconfig.json | 4 +- packages/e2ee/vitest.config.ts | 7 + yarn.lock | 244 +----------------- 11 files changed, 187 insertions(+), 289 deletions(-) create mode 100644 packages/e2ee/src/__tests__/.oxlintrc.json create mode 100644 packages/e2ee/src/__tests__/vector.test.ts create mode 100644 packages/e2ee/src/binary.ts create mode 100644 packages/e2ee/src/vector.ts create mode 100644 packages/e2ee/vitest.config.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 5150356881f2f..16fc27cc777ca 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -14,7 +14,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -import { toString, toArrayBuffer, joinVectorAndEcryptedData, splitVectorAndEcryptedData } from './helper'; +import { toString, splitVectorAndEcryptedData } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; @@ -499,19 +499,12 @@ class E2E extends Emitter<{ } async encodePrivateKey(privateKey: string, password: string): Promise { - const masterKey = await this.e2ee.getMasterKey(password); - const vector = this.e2ee.codec.getRandomUint8Array(16); - try { - if (!masterKey.isOk) { - throw new Error('Error getting master key'); - } - const encodedPrivateKey = await this.e2ee.codec.crypto.encryptAesCbc(masterKey.value, vector, toArrayBuffer(privateKey)); - - return this.e2ee.codec.stringifyUint8Array(joinVectorAndEcryptedData(vector, encodedPrivateKey)); - } catch (error) { - this.setState('ERROR'); - return this.error('Error encrypting encodedPrivateKey: ', error); + const res = await this.e2ee.encodePrivateKey(privateKey, password); + if (res.isOk) { + return res.value; } + this.setState('ERROR'); + return this.error('Error encoding private key: ', res.error); } async getMasterKey(password: string): Promise { diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index efba6fc5451c0..d434dffeed72f 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -20,7 +20,7 @@ "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "oxlint --type-aware", - "testunit": "node --experimental-strip-types --test" + "testunit": "vitest run" }, "devDependencies": { "@noble/ciphers": "~1.3.0", @@ -29,7 +29,7 @@ "oxlint": "~1.12.0", "oxlint-tsgolint": "~0.0.4", "typescript": "~5.9.2", - "vitest": "" + "vitest": "4.0.0-beta.8" }, "license": "MIT", "publishConfig": { diff --git a/packages/e2ee/src/__tests__/.oxlintrc.json b/packages/e2ee/src/__tests__/.oxlintrc.json new file mode 100644 index 0000000000000..329fae1135eb5 --- /dev/null +++ b/packages/e2ee/src/__tests__/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-magic-numbers": "allow" + } +} \ No newline at end of file diff --git a/packages/e2ee/src/__tests__/codec.test.ts b/packages/e2ee/src/__tests__/codec.test.ts index 798068b8eac6c..c27b1b9f02444 100644 --- a/packages/e2ee/src/__tests__/codec.test.ts +++ b/packages/e2ee/src/__tests__/codec.test.ts @@ -1,53 +1,65 @@ -// import { BaseKeyCodec, type CryptoProvider } from '../key-codec.ts'; +// import { cbc } from '@noble/ciphers/aes.js'; +// import { expect, test } from 'vitest'; +// import { BaseKeyCodec } from '../codec.ts'; // class KeyCodec extends BaseKeyCodec { // constructor() { // super({ -// decodeBase64: (input) => { -// const binaryString = atob(data) -// } +// decryptAesCbc(key, iv, data) { +// return cbc(key, iv).encrypt(data); +// }, // }); // } // } +// test('KeyCodec stringifyUint8Array', () => { +// const data = new Uint8Array([0x01, 2, 3]); +// const jsonString = codec.stringifyUint8Array(data); +// expect(jsonString).toBe('{"$binary":"AQID"}'); +// }); + +// test('KeyCodec parseUint8Array', () => { +// const jsonString = '{"$binary":"AQID"}'; +// const data = codec.parseUint8Array(jsonString); +// expect(data).toEqual(new Uint8Array([1, 2, 3])); +// }); + // test('KeyCodec roundtrip (v1 structured encoding)', async () => { -// const codec = new KeyCodec(testCrypto); -// const { privateJWK } = await codec.generateRSAKeyPair(); -// const privStr = JSON.stringify(privateJWK); -// const masterKey = await codec.deriveMasterKey('pass123', new TextEncoder().encode('salt-user-1')); -// const enc = await codec.encodePrivateKey(privStr, masterKey); +// const { privateJWK } = await codec.generateRsaOaepKeyPair(); + +// const saltBuffer = codec.encodeSalt('salt-user-1'); +// const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); +// const enc = await codec.encodePrivateKey(privateJWK, masterKey); // const dec = await codec.decodePrivateKey(enc, masterKey); -// assert.equal(dec, privStr); +// expect(dec).toStrictEqual(privateJWK); // }); // test('KeyCodec wrong password fails', async () => { -// const codec = new KeyCodec(deps); -// const { privateJWK } = await codec.generateRSAKeyPair(); -// const privStr = JSON.stringify(privateJWK); -// const salt1 = new TextEncoder().encode('salt1'); -// const masterKey = await codec.deriveMasterKey('correct', salt1); -// const masterKey2 = await codec.deriveMasterKey('incorrect', salt1); -// const enc = await codec.encodePrivateKey(privStr, masterKey); -// await assert.rejects(() => codec.decodePrivateKey(enc, masterKey2)); +// const { privateJWK } = await codec.generateRsaOaepKeyPair(); +// const salt1 = codec.encodeSalt('salt1'); +// const masterKey = await codec.deriveMasterKey(salt1, 'correct'); +// const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); +// const enc = await codec.encodePrivateKey(privateJWK, masterKey); +// await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); // }); // test('KeyCodec tamper detection (ciphertext)', async () => { -// const codec = new KeyCodec(deps); -// const { privateJWK } = await codec.generateRSAKeyPair(); -// const privStr = JSON.stringify(privateJWK); -// const masterKey = await codec.deriveMasterKey('pw', new TextEncoder().encode('salt')); -// const enc = await codec.encodePrivateKey(privStr, masterKey); +// const { privateJWK } = await codec.generateRsaOaepKeyPair(); +// const salt = codec.encodeSalt('salt'); +// const masterKey = await codec.deriveMasterKey(salt, 'pw'); +// const enc = await codec.encodePrivateKey(privateJWK, masterKey); // // Tamper with ctB64 // const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; -// await assert.rejects(() => codec.decodePrivateKey(mutated, masterKey)); +// await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); // }); // test('KeyCodec legacy roundtrip', async () => { -// const codec = new KeyCodec(deps); -// const { privateJWK } = await codec.generateRSAKeyPair(); +// const { privateJWK } = await codec.generateRsaOaepKeyPair(); // const privStr = JSON.stringify(privateJWK); // const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); -// const dec = await codec.legacyDecrypt(blob, 'pw', 'salt'); -// assert.equal(dec, privStr); +// const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); +// const dec = typeof decAny === 'string' ? JSON.parse(decAny) : decAny; +// expect(dec).toStrictEqual(privateJWK); // }); + export {}; diff --git a/packages/e2ee/src/__tests__/vector.test.ts b/packages/e2ee/src/__tests__/vector.test.ts new file mode 100644 index 0000000000000..93d468586a628 --- /dev/null +++ b/packages/e2ee/src/__tests__/vector.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest'; +import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from '../vector.ts'; + +test('joinVectorAndEncryptedData and splitVectorAndEncryptedData', () => { + const [vector, encryptedData] = splitVectorAndEncryptedData( + joinVectorAndEncryptedData( + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), + new Uint8Array([17, 18, 19, 20]).buffer, + ), + ); + expect(vector).toMatchInlineSnapshot(` + Uint8Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + ] + `); + expect(encryptedData).toMatchInlineSnapshot(` + Uint8Array [ + 17, + 18, + 19, + 20, + ] + `); +}); diff --git a/packages/e2ee/src/binary.ts b/packages/e2ee/src/binary.ts new file mode 100644 index 0000000000000..be019d8a09d4c --- /dev/null +++ b/packages/e2ee/src/binary.ts @@ -0,0 +1,38 @@ +// Returns the input unchanged if it's already a string; otherwise treats the input +// as an ArrayBuffer / TypedArray / DataView whose bytes become 1:1 code points in the string. +export const toString = (data: Uint8Array): string => { + if (typeof data === 'string') { + return data; + } + + const u8: Uint8Array = new Uint8Array(data.length); + + // Convert in chunks to avoid exceeding argument length limits (spread arg cap) + const CHUNK_SIZE = 32_768; // 32 KB per chunk + let result = ''; + for (let offset = 0; offset < u8.length; offset += CHUNK_SIZE) { + const slice = u8.subarray(offset, offset + CHUNK_SIZE); + result += String.fromCodePoint(...slice); + } + return result; +}; + +// Converts a "binary string" (each charCode 0–255) to an ArrayBuffer. +// Returns undefined if input is undefined; passes through existing ArrayBuffer; copies TypedArray/DataView. +export const toArrayBuffer = (data: string): Uint8Array => { + if (!data) { + return new Uint8Array(); + } + + const BYTE_RANGE = 256; + const FALLBACK_CODE_POINT = 0; + const INCREMENT = 1; + const len = data.length; + const u8 = new Uint8Array(len); + for (let i = 0; i < len; i += INCREMENT) { + // Mimic old ByteBuffer 'binary' behavior: low 8 bits only + const code = data.codePointAt(i) ?? FALLBACK_CODE_POINT; + u8[i] = code % BYTE_RANGE; + } + return u8; +}; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 58c26119b2a7e..38697debd4c4e 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,5 +1,7 @@ import { type Optional, type Result, err, ok } from './utils.ts'; import type { BaseKeyCodec } from './codec.ts'; +import { joinVectorAndEncryptedData } from './vector.ts'; +import { toArrayBuffer } from './binary.ts'; export { BaseKeyCodec } from './codec.ts'; @@ -84,17 +86,30 @@ export abstract class BaseE2EE { } } - async loadKeysFromDB(callbacks: { - onSuccess: (keys: KeyPair) => void; - onError: (error: unknown) => void; - }): Promise { + async loadKeysFromDB(callbacks: { onSuccess: (keys: KeyPair) => void; onError: (error: unknown) => void }): Promise { try { - callbacks.onSuccess((await this.getKeysFromService())); + callbacks.onSuccess(await this.getKeysFromService()); } catch (error) { callbacks.onError(error); } } + async encodePrivateKey(privateKey: string, password: string): Promise> { + const masterKey = await this.getMasterKey(password); + if (!masterKey.isOk) { + return masterKey; + } + const IV_LENGTH = 16; + const vector = this.#codec.getRandomUint8Array(IV_LENGTH); + try { + const encodedPrivateKey = await this.#codec.crypto.encryptAesCbc(masterKey.value, vector, toArrayBuffer(privateKey)); + + return ok(this.#codec.stringifyUint8Array(joinVectorAndEncryptedData(vector, encodedPrivateKey))); + } catch (error) { + return err(new Error('Error encrypting encodedPrivateKey', { cause: error })); + } + } + /// Low-level key management methods /** diff --git a/packages/e2ee/src/vector.ts b/packages/e2ee/src/vector.ts new file mode 100644 index 0000000000000..dc0093345b6d8 --- /dev/null +++ b/packages/e2ee/src/vector.ts @@ -0,0 +1,23 @@ +const ZERO = 0x00; +const IV_LENGTH = 0x10; + +export const joinVectorAndEncryptedData = (vector: Uint8Array, cipherText: ArrayBuffer): Uint8Array => { + if (vector.length !== IV_LENGTH) { + throw new Error(`Invalid vector length: ${vector.length}`); + } + + const output = new Uint8Array(vector.length + cipherText.byteLength); + + output.set(vector); + output.set(new Uint8Array(cipherText), vector.length); + + return output; +}; + +export const splitVectorAndEncryptedData = (cipherText: Uint8Array): [Uint8Array, Uint8Array] => { + if (cipherText.length <= IV_LENGTH) { + throw new Error(`Invalid cipherText length: ${cipherText.length}`); + } + + return [cipherText.slice(ZERO, IV_LENGTH), cipherText.slice(IV_LENGTH)]; +}; diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json index e4957aecc24b7..8ef0b9670df27 100644 --- a/packages/e2ee/tsconfig.json +++ b/packages/e2ee/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "composite": false, - "module": "es2022", + "module": "preserve", "target": "ES2024", "lib": ["ESNext", "WebWorker"], "types": [], @@ -9,6 +9,7 @@ /** Modules */ "rootDir": "src", + "moduleResolution": "bundler", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, @@ -18,6 +19,7 @@ "declaration": true, "declarationMap": true, "verbatimModuleSyntax": true, + "moduleDetection": "force", /** Language and Environment */ diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts new file mode 100644 index 0000000000000..1c73ca943465f --- /dev/null +++ b/packages/e2ee/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + fileParallelism: false, + }, +}); diff --git a/yarn.lock b/yarn.lock index b0eca161b0f4e..32ee7d3827eb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7446,7 +7446,7 @@ __metadata: oxlint: "npm:~1.12.0" oxlint-tsgolint: "npm:~0.0.4" typescript: "npm:~5.9.2" - vitest: "npm:" + vitest: "npm:4.0.0-beta.8" languageName: unknown linkType: soft @@ -13471,19 +13471,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/expect@npm:3.2.4" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - tinyrainbow: "npm:^2.0.0" - checksum: 10/dc69ce886c13714dfbbff78f2d2cb7eb536017e82301a73c42d573a9e9d2bf91005ac7abd9b977adf0a3bd431209f45a8ac2418029b68b0a377e092607c843ce - languageName: node - linkType: hard - "@vitest/expect@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/expect@npm:4.0.0-beta.8" @@ -13497,25 +13484,6 @@ __metadata: languageName: node linkType: hard -"@vitest/mocker@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/mocker@npm:3.2.4" - dependencies: - "@vitest/spy": "npm:3.2.4" - estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - checksum: 10/5e92431b6ed9fc1679060e4caef3e4623f4750542a5d7cd944774f8217c4d231e273202e8aea00bab33260a5a9222ecb7005d80da0348c3c829bd37d123071a8 - languageName: node - linkType: hard - "@vitest/mocker@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/mocker@npm:4.0.0-beta.8" @@ -13553,15 +13521,6 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/pretty-format@npm:3.2.4" - dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10/8dd30cbf956e01fbab042fe651fb5175d9f0cd00b7b569a46cd98df89c4fec47dab12916201ad6e09a4f25f2a2ec8927a4bfdc61118593097f759c90b18a51d4 - languageName: node - linkType: hard - "@vitest/pretty-format@npm:4.0.0-beta.8, @vitest/pretty-format@npm:^4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/pretty-format@npm:4.0.0-beta.8" @@ -13571,17 +13530,6 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/runner@npm:3.2.4" - dependencies: - "@vitest/utils": "npm:3.2.4" - pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10/197bd55def519ef202f990b7c1618c212380831827c116240871033e4973decb780503c705ba9245a12bd8121f3ac4086ffcb3e302148b62d9bd77fd18dd1deb - languageName: node - linkType: hard - "@vitest/runner@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/runner@npm:4.0.0-beta.8" @@ -13593,17 +13541,6 @@ __metadata: languageName: node linkType: hard -"@vitest/snapshot@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/snapshot@npm:3.2.4" - dependencies: - "@vitest/pretty-format": "npm:3.2.4" - magic-string: "npm:^0.30.17" - pathe: "npm:^2.0.3" - checksum: 10/acfb682491b9ca9345bf9fed02c2779dec43e0455a380c1966b0aad8dd81c79960902cf34621ab48fe80a0eaf8c61cc42dec186a1321dc3c9897ef2ebd5f1bc4 - languageName: node - linkType: hard - "@vitest/snapshot@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/snapshot@npm:4.0.0-beta.8" @@ -13624,15 +13561,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/spy@npm:3.2.4" - dependencies: - tinyspy: "npm:^4.0.3" - checksum: 10/7d38c299f42a8c7e5e41652b203af98ca54e63df69c3b072d0e401d5a57fbbba3e39d8538ac1b3022c26718a6388d0bcc222bc2f07faab75942543b9247c007d - languageName: node - linkType: hard - "@vitest/spy@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/spy@npm:4.0.0-beta.8" @@ -13652,17 +13580,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/utils@npm:3.2.4" - dependencies: - "@vitest/pretty-format": "npm:3.2.4" - loupe: "npm:^3.1.4" - tinyrainbow: "npm:^2.0.0" - checksum: 10/7f12ef63bd8ee13957744d1f336b0405f164ade4358bf9dfa531f75bbb58ffac02bf61aba65724311ddbc50b12ba54853a169e59c6b837c16086173b9a480710 - languageName: node - linkType: hard - "@vitest/utils@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "@vitest/utils@npm:4.0.0-beta.8" @@ -16015,13 +15932,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10/002769a0fbfc51c062acd2a59df465a2a947916b02ac50b56c69ec6c018ee99ac3e7f4dd7366334ea847f1ecacf4defaa61bcd2ac283db50156ce1f1d8c8ad42 - languageName: node - linkType: hard - "cacache@npm:^15.0.5": version: 15.3.0 resolution: "cacache@npm:15.3.0" @@ -16333,19 +16243,6 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.2.0": - version: 5.3.1 - resolution: "chai@npm:5.3.1" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10/0d801e3e4584dcbba6dbf415b5ea801dbf069f328560aa4d4f9b016e9a362dd58cb44775798e2130f4ad639385cad85dc486f995e672713369a44fd33702dcbf - languageName: node - linkType: hard - "chai@npm:^5.2.1": version: 5.2.1 resolution: "chai@npm:5.2.1" @@ -20400,7 +20297,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.1, expect-type@npm:^1.2.2": +"expect-type@npm:^1.2.2": version: 1.2.2 resolution: "expect-type@npm:1.2.2" checksum: 10/1703e6e47b575f79d801d87f24c639f4d0af71b327a822e6922d0ccb7eb3f6559abb240b8bd43bab6a477903de4cc322908e194d05132c18f52a217115e8e870 @@ -20707,7 +20604,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4, fdir@npm:^6.4.6, fdir@npm:^6.5.0": +"fdir@npm:^6.4.4, fdir@npm:^6.4.6": version: 6.5.0 resolution: "fdir@npm:6.5.0" peerDependencies: @@ -26357,7 +26254,7 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.4, loupe@npm:^3.2.0": +"loupe@npm:^3.2.0": version: 3.2.0 resolution: "loupe@npm:3.2.0" checksum: 10/80d48e35b014c2ba5886e25a02ee4cc9c1f659b0eca9c4fa8f07051cdc689e0507a763fbe05a63abbd3b7d4640774a722c905d4b1681b4b92c3ba8f87d96fea2 @@ -35281,13 +35178,6 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^4.0.3": - version: 4.0.3 - resolution: "tinyspy@npm:4.0.3" - checksum: 10/b6a3ed40dd76a2b3c020250cf1401506b456509d1fb9dba0c7b0e644d258dac722843b85c57ccc36c8687db1e7978cb6adcc43e3b71c475910c085b96d41cb53 - languageName: node - linkType: hard - "tlds@npm:1.259.0": version: 1.259.0 resolution: "tlds@npm:1.259.0" @@ -36781,76 +36671,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.2.4": - version: 3.2.4 - resolution: "vite-node@npm:3.2.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - pathe: "npm:^2.0.3" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - bin: - vite-node: vite-node.mjs - checksum: 10/343244ecabbab3b6e1a3065dabaeefa269965a7a7c54652d4b7a7207ee82185e887af97268c61755dcb2dd6a6ce5d9e114400cbd694229f38523e935703cc62f - languageName: node - linkType: hard - -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": - version: 7.1.3 - resolution: "vite@npm:7.1.3" - dependencies: - esbuild: "npm:^0.25.0" - fdir: "npm:^6.5.0" - fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.3" - postcss: "npm:^8.5.6" - rollup: "npm:^4.43.0" - tinyglobby: "npm:^0.2.14" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - jiti: ">=1.21.0" - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10/792cba6d5090cc6713a1f72ee43578902df0e231db0b4d313589ec380919d3612a01903615e183ebca6a903f83ee4fbfceb3d07a5443d5698d5d992dbc60a995 - languageName: node - linkType: hard - "vite@npm:^6.0.0 || ^7.0.0-0": version: 7.1.2 resolution: "vite@npm:7.1.2" @@ -36958,62 +36778,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:": - version: 3.2.4 - resolution: "vitest@npm:3.2.4" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" - "@vitest/pretty-format": "npm:^3.2.4" - "@vitest/runner": "npm:3.2.4" - "@vitest/snapshot": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - debug: "npm:^4.4.1" - expect-type: "npm:^1.2.1" - magic-string: "npm:^0.30.17" - pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.2" - std-env: "npm:^3.9.0" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node: "npm:3.2.4" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/debug": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10/f10bbce093ecab310ecbe484536ef4496fb9151510b2be0c5907c65f6d31482d9c851f3182531d1d27d558054aa78e8efd9d4702ba6c82058657e8b6a52507ee - languageName: node - linkType: hard - "vitest@npm:4.0.0-beta.8": version: 4.0.0-beta.8 resolution: "vitest@npm:4.0.0-beta.8" From e0ce6b30d5b69f8a3c0f83c6248914a32aee15f1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 12:31:55 -0300 Subject: [PATCH 037/251] decode private key --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 23 ++---- packages/e2ee/src/__tests__/codec.test.ts | 65 --------------- packages/e2ee/src/__tests__/e2ee.test.ts | 82 ------------------- packages/e2ee/src/binary.ts | 22 ++--- packages/e2ee/src/index.ts | 20 ++++- 5 files changed, 34 insertions(+), 178 deletions(-) delete mode 100644 packages/e2ee/src/__tests__/codec.test.ts delete mode 100644 packages/e2ee/src/__tests__/e2ee.test.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 16fc27cc777ca..fe860f9c5cce0 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -602,23 +602,14 @@ class E2E extends Emitter<{ async decodePrivateKey(privateKey: string): Promise { const password = await this.requestPasswordAlert(); - - const masterKey = await this.getMasterKey(password); - - const [vector, cipherText] = splitVectorAndEcryptedData(this.e2ee.codec.parseUint8Array(privateKey)); - - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await this.e2ee.codec.crypto.decryptAesCbc(masterKey, vector, cipherText); - return toString(privKey); - } catch { - this.setState('ENTER_PASSWORD'); - dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); - dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key'); + const res = await this.e2ee.decodePrivateKey(privateKey, password); + if (res.isOk) { + return res.value; } + this.setState('ENTER_PASSWORD'); + dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); + dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); + throw new Error('E2E -> Error decrypting private key'); } async decryptFileContent(file: IUploadWithUser): Promise { diff --git a/packages/e2ee/src/__tests__/codec.test.ts b/packages/e2ee/src/__tests__/codec.test.ts deleted file mode 100644 index c27b1b9f02444..0000000000000 --- a/packages/e2ee/src/__tests__/codec.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// import { cbc } from '@noble/ciphers/aes.js'; -// import { expect, test } from 'vitest'; -// import { BaseKeyCodec } from '../codec.ts'; - -// class KeyCodec extends BaseKeyCodec { -// constructor() { -// super({ -// decryptAesCbc(key, iv, data) { -// return cbc(key, iv).encrypt(data); -// }, -// }); -// } -// } - -// test('KeyCodec stringifyUint8Array', () => { -// const data = new Uint8Array([0x01, 2, 3]); -// const jsonString = codec.stringifyUint8Array(data); -// expect(jsonString).toBe('{"$binary":"AQID"}'); -// }); - -// test('KeyCodec parseUint8Array', () => { -// const jsonString = '{"$binary":"AQID"}'; -// const data = codec.parseUint8Array(jsonString); -// expect(data).toEqual(new Uint8Array([1, 2, 3])); -// }); - -// test('KeyCodec roundtrip (v1 structured encoding)', async () => { -// const { privateJWK } = await codec.generateRsaOaepKeyPair(); - -// const saltBuffer = codec.encodeSalt('salt-user-1'); -// const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); -// const enc = await codec.encodePrivateKey(privateJWK, masterKey); -// const dec = await codec.decodePrivateKey(enc, masterKey); -// expect(dec).toStrictEqual(privateJWK); -// }); - -// test('KeyCodec wrong password fails', async () => { -// const { privateJWK } = await codec.generateRsaOaepKeyPair(); -// const salt1 = codec.encodeSalt('salt1'); -// const masterKey = await codec.deriveMasterKey(salt1, 'correct'); -// const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); -// const enc = await codec.encodePrivateKey(privateJWK, masterKey); -// await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); -// }); - -// test('KeyCodec tamper detection (ciphertext)', async () => { -// const { privateJWK } = await codec.generateRsaOaepKeyPair(); -// const salt = codec.encodeSalt('salt'); -// const masterKey = await codec.deriveMasterKey(salt, 'pw'); -// const enc = await codec.encodePrivateKey(privateJWK, masterKey); -// // Tamper with ctB64 -// const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; -// await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); -// }); - -// test('KeyCodec legacy roundtrip', async () => { -// const { privateJWK } = await codec.generateRsaOaepKeyPair(); -// const privStr = JSON.stringify(privateJWK); -// const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); -// const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); -// const dec = typeof decAny === 'string' ? JSON.parse(decAny) : decAny; -// expect(dec).toStrictEqual(privateJWK); -// }); - -export {}; diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts deleted file mode 100644 index fda48dc45e7b1..0000000000000 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// import { BaseE2EE, type KeyPair, type KeyService, type KeyStorage } from '../index.ts'; -// import {} - -// class TestE2EE extends BaseE2EE { -// constructor() { -// super({ - -// }) -// } -// } - -// class MemoryStorage implements KeyStorage { -// private map = new Map(); - -// load(keyName: string): Promise { -// return Promise.resolve(this.map.get(keyName) ?? null); -// } -// store(keyName: string, value: string): Promise { -// this.map.set(keyName, value); -// return Promise.resolve(); -// } -// remove(keyName: string): Promise { -// this.map.delete(keyName); -// return Promise.resolve(); -// } -// } - -// class DeterministicCrypto { -// private seq: number[]; -// private idx = 0; -// constructor(seq: number[], subtle: webcrypto.SubtleCrypto, CryptoKey: webcrypto.CryptoKeyConstructor) { -// this.seq = seq; -// // Use provided SubtleCrypto, else fall back to environment's, else a no-op placeholder. -// this.subtle = subtle; -// this.CryptoKey = CryptoKey; -// } -// CryptoKey: webcrypto.CryptoKeyConstructor; -// subtle: webcrypto.SubtleCrypto; -// getRandomValues(array: T): T { -// if (array instanceof Uint32Array) { -// for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]!; -// } else if (array instanceof Uint8Array) { -// for (let i = 0; i < array.length; i++) array[i] = this.seq[this.idx++ % this.seq.length]! & 0xff; -// } -// return array; -// } -// get [Symbol.toStringTag]() { -// return 'DeterministicCrypto'; -// } -// randomUUID(): `${string}-${string}-${string}-${string}-${string}` { -// const bytes = new Uint8Array(16); -// this.getRandomValues(bytes); -// // RFC 4122 variant & version adjustments -// bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 -// bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10 -// const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); -// return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}` as `${string}-${string}-${string}-${string}-${string}`; -// } -// } - -// class MockedKeyService implements KeyService { -// fetchMyKeys(): Promise { -// return Promise.resolve({ -// public_key: 'mocked_public_key', -// private_key: 'mocked_private_key', -// }); -// } -// } - -// test('E2EE createRandomPassword deterministic generation with 5 words', async () => { -// const storage = new MemoryStorage(); -// // Inject custom word list by temporarily defining dynamic import. -// // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. -// // So we skip testing exact phrase (depends on wordList) and just assert shape. -// const deterministicCrypto = new DeterministicCrypto([1, 2, 3, 4, 5], webcrypto.subtle, webcrypto.CryptoKey); -// const mockedKeyService = new MockedKeyService(); -// const e2ee = new E2EE(storage, deterministicCrypto, mockedKeyService); -// const pwd = await e2ee.createRandomPassword(); -// assert.equal(pwd.split(' ').length, 5); -// assert.equal(await e2ee.getRandomPassword(), pwd); -// }); -export {}; diff --git a/packages/e2ee/src/binary.ts b/packages/e2ee/src/binary.ts index be019d8a09d4c..758f7ec50087b 100644 --- a/packages/e2ee/src/binary.ts +++ b/packages/e2ee/src/binary.ts @@ -1,18 +1,14 @@ // Returns the input unchanged if it's already a string; otherwise treats the input -// as an ArrayBuffer / TypedArray / DataView whose bytes become 1:1 code points in the string. -export const toString = (data: Uint8Array): string => { - if (typeof data === 'string') { - return data; - } - - const u8: Uint8Array = new Uint8Array(data.length); +// as an ArrayBuffer / TypedArray whose bytes become 1:1 char codes in the string. +export const toString = (data: ArrayBuffer): string => { + // Accept ArrayBuffer, TypedArray, DataView + const u8 = new Uint8Array(data); - // Convert in chunks to avoid exceeding argument length limits (spread arg cap) - const CHUNK_SIZE = 32_768; // 32 KB per chunk + // Convert in chunks to avoid exceeding argument length limits + const CHUNK = 0x80_00; let result = ''; - for (let offset = 0; offset < u8.length; offset += CHUNK_SIZE) { - const slice = u8.subarray(offset, offset + CHUNK_SIZE); - result += String.fromCodePoint(...slice); + for (let i = 0; i < u8.length; i += CHUNK) { + result += String.fromCodePoint(...u8.subarray(i, i + CHUNK)); } return result; }; @@ -24,7 +20,7 @@ export const toArrayBuffer = (data: string): Uint8Array => { return new Uint8Array(); } - const BYTE_RANGE = 256; + const BYTE_RANGE = 0x1_00; // 256 possible byte values const FALLBACK_CODE_POINT = 0; const INCREMENT = 1; const len = data.length; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 38697debd4c4e..263c8b297c18f 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,7 +1,7 @@ import { type Optional, type Result, err, ok } from './utils.ts'; +import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; +import { toArrayBuffer, toString } from './binary.ts'; import type { BaseKeyCodec } from './codec.ts'; -import { joinVectorAndEncryptedData } from './vector.ts'; -import { toArrayBuffer } from './binary.ts'; export { BaseKeyCodec } from './codec.ts'; @@ -110,6 +110,22 @@ export abstract class BaseE2EE { } } + async decodePrivateKey(privateKey: string, password: string): Promise> { + const masterKey = await this.getMasterKey(password); + if (!masterKey.isOk) { + return masterKey; + } + + const [vector, cipherText] = splitVectorAndEncryptedData(this.#codec.parseUint8Array(privateKey)); + + try { + const privKey = await this.#codec.crypto.decryptAesCbc(masterKey.value, vector, cipherText); + return ok(toString(privKey)); + } catch (error) { + return err(new Error('Error decrypting private key', { cause: error })); + } + } + /// Low-level key management methods /** From 56cf6f7465d527d200708da665cc398fe4c3a7e4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 13:00:27 -0300 Subject: [PATCH 038/251] decode private key flow --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index fe860f9c5cce0..e8bf3fb4f733d 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -14,7 +14,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -import { toString, splitVectorAndEcryptedData } from './helper'; +// import { toString, splitVectorAndEcryptedData } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; @@ -569,35 +569,29 @@ class E2E extends Emitter<{ async decodePrivateKeyFlow() { const password = await this.requestPasswordModal(); - const masterKey = await this.getMasterKey(password); if (!this.db_private_key) { return; } - const [vector, cipherText] = splitVectorAndEcryptedData(this.e2ee.codec.parseUint8Array(this.db_private_key)); + const res = await this.e2ee.decodePrivateKey(this.db_private_key, password); - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await this.e2ee.codec.crypto.decryptAesCbc(masterKey, vector, cipherText); - const privateKey = toString(privKey) as string; - - if (this.db_public_key && privateKey) { - await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); - this.setState('READY'); - } else { - await this.createAndLoadKeys(); - this.setState('READY'); - } - dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); - } catch { + if (!res.isOk) { this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key'); + throw res.error; + } + + const privateKey = res.value; + + if (this.db_public_key && privateKey) { + await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); + } else { + await this.createAndLoadKeys(); } + + this.setState('READY'); } async decodePrivateKey(privateKey: string): Promise { From 2a7d014faebfab9af86e0eedcb490ae4cbd33795 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 13:03:33 -0300 Subject: [PATCH 039/251] remove commented out import --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index e8bf3fb4f733d..03dd5d63ef841 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -14,7 +14,6 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -// import { toString, splitVectorAndEcryptedData } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; From d7685c318364006c54b1d1bfebcaef3df2ba5cca Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 13:45:15 -0300 Subject: [PATCH 040/251] improve tests --- packages/e2ee/src/__tests__/binary.test.ts | 22 ++++++++++++++++++++++ packages/e2ee/src/__tests__/vector.test.ts | 12 ++++++++++++ packages/e2ee/src/binary.ts | 5 ----- packages/e2ee/src/vector.ts | 4 ++-- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 packages/e2ee/src/__tests__/binary.test.ts diff --git a/packages/e2ee/src/__tests__/binary.test.ts b/packages/e2ee/src/__tests__/binary.test.ts new file mode 100644 index 0000000000000..b9604907e8463 --- /dev/null +++ b/packages/e2ee/src/__tests__/binary.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { toArrayBuffer, toString } from '../binary.ts'; + +test('toArrayBuffer', () => { + expect(toArrayBuffer('')).toMatchInlineSnapshot(`Uint8Array []`); + expect(toArrayBuffer('hello')).toMatchInlineSnapshot(` + Uint8Array [ + 104, + 101, + 108, + 108, + 111, + ] + `); +}); + +test('toString', () => { + expect(toString(new Uint8Array([]).buffer)).toMatchInlineSnapshot(`""`); + expect(toString(new Uint8Array([104, 101, 108, 108, 111]).buffer)).toMatchInlineSnapshot(` + "hello" + `); +}); diff --git a/packages/e2ee/src/__tests__/vector.test.ts b/packages/e2ee/src/__tests__/vector.test.ts index 93d468586a628..10a52854d47cb 100644 --- a/packages/e2ee/src/__tests__/vector.test.ts +++ b/packages/e2ee/src/__tests__/vector.test.ts @@ -37,3 +37,15 @@ test('joinVectorAndEncryptedData and splitVectorAndEncryptedData', () => { ] `); }); + +test('joinVectorAndEncryptedData with invalid vector length', () => { + expect(() => + joinVectorAndEncryptedData(new Uint8Array([1, 2, 3]), new Uint8Array([17, 18, 19, 20]).buffer), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid vector length]`); +}); + +test('splitVectorAndEncryptedData with invalid cipherText length', () => { + expect(() => splitVectorAndEncryptedData(new Uint8Array([1, 2, 3]))).toThrowErrorMatchingInlineSnapshot( + `[Error: Invalid cipherText length: 3]`, + ); +}); diff --git a/packages/e2ee/src/binary.ts b/packages/e2ee/src/binary.ts index 758f7ec50087b..3d9d57837afe4 100644 --- a/packages/e2ee/src/binary.ts +++ b/packages/e2ee/src/binary.ts @@ -1,7 +1,6 @@ // Returns the input unchanged if it's already a string; otherwise treats the input // as an ArrayBuffer / TypedArray whose bytes become 1:1 char codes in the string. export const toString = (data: ArrayBuffer): string => { - // Accept ArrayBuffer, TypedArray, DataView const u8 = new Uint8Array(data); // Convert in chunks to avoid exceeding argument length limits @@ -16,10 +15,6 @@ export const toString = (data: ArrayBuffer): string => { // Converts a "binary string" (each charCode 0–255) to an ArrayBuffer. // Returns undefined if input is undefined; passes through existing ArrayBuffer; copies TypedArray/DataView. export const toArrayBuffer = (data: string): Uint8Array => { - if (!data) { - return new Uint8Array(); - } - const BYTE_RANGE = 0x1_00; // 256 possible byte values const FALLBACK_CODE_POINT = 0; const INCREMENT = 1; diff --git a/packages/e2ee/src/vector.ts b/packages/e2ee/src/vector.ts index dc0093345b6d8..a527e9cac7bcf 100644 --- a/packages/e2ee/src/vector.ts +++ b/packages/e2ee/src/vector.ts @@ -3,7 +3,7 @@ const IV_LENGTH = 0x10; export const joinVectorAndEncryptedData = (vector: Uint8Array, cipherText: ArrayBuffer): Uint8Array => { if (vector.length !== IV_LENGTH) { - throw new Error(`Invalid vector length: ${vector.length}`); + throw new Error('Invalid vector length'); } const output = new Uint8Array(vector.length + cipherText.byteLength); @@ -16,7 +16,7 @@ export const joinVectorAndEncryptedData = (vector: Uint8Array, ciph export const splitVectorAndEncryptedData = (cipherText: Uint8Array): [Uint8Array, Uint8Array] => { if (cipherText.length <= IV_LENGTH) { - throw new Error(`Invalid cipherText length: ${cipherText.length}`); + throw new Error('Invalid cipherText length'); } return [cipherText.slice(ZERO, IV_LENGTH), cipherText.slice(IV_LENGTH)]; From b4c2d482c35cc134b49c2d13c627017107715ff2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 21 Aug 2025 16:15:06 -0300 Subject: [PATCH 041/251] remove readme for now --- packages/e2ee-web/README.md | 98 ------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 packages/e2ee-web/README.md diff --git a/packages/e2ee-web/README.md b/packages/e2ee-web/README.md deleted file mode 100644 index 46e1e13e99b36..0000000000000 --- a/packages/e2ee-web/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# @rocket.chat/e2e-crypto-core - -Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: - -- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) -- Double Ratchet skeleton for forward secrecy -- Group key derivation & rotation helpers -- Message serialization & integrity (MAC) helpers -- Replay protection utilities -- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage - -> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. - -## Design Principles - -1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. -2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. -3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). -4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. -5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. - -## Core Modules - -| Module | Purpose | -| ------ | ------- | -| `providers/interfaces.ts` | Abstract provider contracts | -| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | -| `core/keyManagement.ts` | Identity & group key helpers | -| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | -| `core/messageIntegrity.ts` | MAC computation / verification | -| `core/replayProtection.ts` | Replay cache + ID helpers | -| `protocol/session.ts` | High-level session manager wrapper | - -## Provider Interfaces - -Implement and inject: - -```ts -import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; - -const ctx: ProviderContext = { - crypto: /* your CryptoProvider */, - rng: /* RandomnessProvider */, - hash: /* HashProvider */, - storage: /* KeyStorageProvider */, - net: /* optional NetworkProvider */, -}; -``` - -## Basic Usage (1:1 Session) - -```ts -import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; - -// ctx: ProviderContext injected -const keyMgr = new KeyManager(ctx); -await keyMgr.ensureIdentityKey(); - -// Assume we fetched peer pre-key bundle externally -const sessionMgr = new SessionManager(ctx); -const init = { - theirIdentityKey: peerBundle.identityKey, - theirSignedPreKey: peerBundle.signedPreKey, - theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], - isInitiator: true, -}; - -// Encrypt -const plaintext = new TextEncoder().encode('hello'); -const payload = await sessionMgr.encrypt('peer-user-id', plaintext); -const wire = serializePayload(payload); // send over DDP / WebSocket / REST - -// Decrypt (other side) -const received = deserializePayload(wire); -const result = await sessionMgr.decrypt('peer-user-id', received); -console.log(new TextDecoder().decode(result.plaintext)); -``` - -## Group Messaging (Conceptual) - -Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. - -## Error Handling - -All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. - -## Extending / Hardening - -Production needs (out of scope of this skeleton): -- Skipped message key handling & storage -- Full DH ratchet step logic & header validation -- Signature verification of pre-key bundles -- Authentic secondary MAC with independent key material -- More robust replay filtering (e.g. bloom filter) -- Formal test vectors & interoperability tests - -## License -MIT From 5e6e4d839860e6e7cee9b3d03900a8471eb8f876 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 13:39:14 -0300 Subject: [PATCH 042/251] use less classes --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 20 +-- packages/e2ee-web/.oxlintrc.json | 22 ++++ packages/e2ee-web/package.json | 4 +- packages/e2ee-web/src/codec.ts | 120 +++++++++--------- packages/e2ee-web/src/index.ts | 58 +++------ packages/e2ee/.oxlintrc.json | 3 +- packages/e2ee/src/__tests__/vector.test.ts | 2 +- packages/e2ee/src/index.ts | 21 +-- packages/e2ee/src/{utils.ts => result.ts} | 1 + yarn.lock | 2 + 10 files changed, 134 insertions(+), 119 deletions(-) create mode 100644 packages/e2ee-web/.oxlintrc.json rename packages/e2ee/src/{utils.ts => result.ts} (87%) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 03dd5d63ef841..52e7f352c9bcc 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,12 +3,10 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { KeyPair } from '@rocket.chat/e2ee'; -import type { Optional } from '@rocket.chat/e2ee/dist/utils'; -import E2EE, { LocalStorage } from '@rocket.chat/e2ee-web'; +import type { LocalKeyPair } from '@rocket.chat/e2ee'; +import E2EE from '@rocket.chat/e2ee-web'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; -import EJSON from 'ejson'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -71,10 +69,13 @@ class E2E extends Emitter<{ this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = new E2EE(new LocalStorage(Accounts.storageLocation), { - userId: () => Promise.resolve(Meteor.userId()), - fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), - }); + this.e2ee = E2EE.withLocalStorage( + { + userId: () => Promise.resolve(Meteor.userId()), + fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), + }, + Accounts.storageLocation, + ); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); @@ -293,7 +294,7 @@ class E2E extends Emitter<{ } private async persistKeys( - { public_key, private_key }: Optional, + { public_key, private_key }: LocalKeyPair, password: string, { force }: { force: boolean } = { force: false }, ): Promise { @@ -466,7 +467,6 @@ class E2E extends Emitter<{ this.setState('ERROR'); this.error('Error loading keys: ', error); }, - parse: (data) => EJSON.parse(data), }); } diff --git a/packages/e2ee-web/.oxlintrc.json b/packages/e2ee-web/.oxlintrc.json new file mode 100644 index 0000000000000..fb5354c3f45de --- /dev/null +++ b/packages/e2ee-web/.oxlintrc.json @@ -0,0 +1,22 @@ +{ + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error", + "restriction": "error", + "style": "error", + "nursery": "error", + "pedantic": "error" + }, + "rules": { + "oxc/no-map-spread": "error", + "oxc/no-async-await": "allow", + "eslint/id-length": "allow", + "eslint/no-undef": "allow", + "eslint/sort-keys": "allow", + "eslint/no-plusplus": "allow", + "eslint/no-magic-numbers": "allow", + "unicorn/prefer-code-point": "allow" + + } +} \ No newline at end of file diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json index db69eef6f8146..40bf39b9e61ec 100644 --- a/packages/e2ee-web/package.json +++ b/packages/e2ee-web/package.json @@ -19,7 +19,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", - "lint": "eslint 'src/**/*.ts'", + "lint": "oxlint --type-aware", "testunit": "vitest run" }, "dependencies": { @@ -27,6 +27,8 @@ }, "devDependencies": { "@vitest/browser": "4.0.0-beta.8", + "oxlint": "~1.12.0", + "oxlint-tsgolint": "~0.0.4", "playwright": "~1.54.2", "typescript": "~5.9.2", "vitest": "4.0.0-beta.8" diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts index 632a41f1747d3..9996fe36a8aca 100644 --- a/packages/e2ee-web/src/codec.ts +++ b/packages/e2ee-web/src/codec.ts @@ -1,66 +1,66 @@ import { BaseKeyCodec } from '@rocket.chat/e2ee'; +import type { CryptoProvider } from '../../e2ee/dist/crypto'; -export default class WebKeyCodec extends BaseKeyCodec { - constructor() { - super({ - exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), - getRandomUint8Array: (array) => crypto.getRandomValues(array), - getRandomUint16Array: (array) => crypto.getRandomValues(array), - getRandomUint32Array: (array) => crypto.getRandomValues(array), - importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), - importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateRsaOaepKeyPair: () => - crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt'], - ), - decodeBase64: (input) => { - const binaryString = atob(input); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - }, - encodeBase64: (input) => { - const bytes = new Uint8Array(input); - let binaryString = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binaryString += String.fromCharCode(bytes[i]!); - } - return btoa(binaryString); - }, - encodeBinary: (input) => { - const encoder = new TextEncoder(); - const dest = new Uint8Array(input.length); - encoder.encodeInto(input, dest); - return dest; +const provider: CryptoProvider = { + exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), + getRandomUint8Array: (array) => crypto.getRandomValues(array), + getRandomUint16Array: (array) => crypto.getRandomValues(array), + getRandomUint32Array: (array) => crypto.getRandomValues(array), + importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), + generateRsaOaepKeyPair: () => + crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ + 'encrypt', + 'decrypt', + ]), + + deriveKeyWithPbkdf2: (salt, baseKey) => + crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 1000, + hash: 'SHA-256', }, - decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), - encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), - encodeUtf8: (input) => new TextEncoder().encode(input), - decodeUtf8: (input) => new TextDecoder().decode(input), - deriveKeyWithPbkdf2: (salt, baseKey) => { - // Align with BaseKeyCodec expectations: derive an AES-CBC 256-bit key with 1000 iterations (legacy compatibility) - // Previous version derived AES-GCM with 100000 iterations, causing algorithm mismatch errors in tests. - return crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt, - iterations: 1000, - hash: 'SHA-256', - }, - baseKey, - { - name: 'AES-CBC', - length: 256, - }, - false, - ['encrypt', 'decrypt'], - ); + baseKey, + { + name: 'AES-CBC', + length: 256, }, - }); + false, + ['encrypt', 'decrypt'], + ), + decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + decodeBase64: (input) => { + const binaryString = atob(input); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + }, + encodeBase64: (input) => { + const bytes = new Uint8Array(input); + let binaryString = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binaryString += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binaryString); + }, + encodeBinary: (input) => { + const encoder = new TextEncoder(); + const dest = new Uint8Array(input.length); + encoder.encodeInto(input, dest); + return dest; + }, + encodeUtf8: (input) => new TextEncoder().encode(input), + decodeUtf8: (input) => new TextDecoder().decode(input), +}; + +export default class WebKeyCodec extends BaseKeyCodec { + constructor() { + super(provider); } } diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts index 0cd81cd3457a9..218a41365d0c4 100644 --- a/packages/e2ee-web/src/index.ts +++ b/packages/e2ee-web/src/index.ts @@ -1,52 +1,36 @@ import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; import KeyCodec from './codec.ts'; -class MemoryStorage implements KeyStorage { - private map = new Map(); - - load(keyName: string): Promise { - return Promise.resolve(this.map.get(keyName) ?? null); - } - store(keyName: string, value: string): Promise { - this.map.set(keyName, value); - return Promise.resolve(); - } - remove(keyName: string): Promise { - this.map.delete(keyName); - return Promise.resolve(); - } -} - -export class LocalStorage implements KeyStorage { - #storage: Storage; - constructor(storage: Storage = globalThis.localStorage) { - this.#storage = storage; - } - load(keyName: string): Promise { - return Promise.resolve(this.#storage.getItem(keyName)); - } - store(keyName: string, value: string): Promise { - this.#storage.setItem(keyName, value); - return Promise.resolve(); - } - remove(keyName: string): Promise { - this.#storage.removeItem(keyName); - return Promise.resolve(); - } -} - export default class WebE2EE extends BaseE2EE { constructor(keyStorage: KeyStorage, keyService: KeyService) { super(new KeyCodec(), keyStorage, keyService); } static withMemoryStorage(keyService: KeyService): WebE2EE { - const memoryStorage = new MemoryStorage(); + const map = new Map(); + const memoryStorage: KeyStorage = { + load: (keyName) => Promise.resolve(map.get(keyName)), + remove: (keyName: string) => Promise.resolve(map.delete(keyName)), + store: (keyName: string, value: string): Promise => { + map.set(keyName, value); + return Promise.resolve(); + }, + }; return new WebE2EE(memoryStorage, keyService); } - static withLocalStorage(keyService: KeyService): WebE2EE { - const localStorage = new LocalStorage(); + static withLocalStorage(keyService: KeyService, storage: Storage = globalThis.localStorage): WebE2EE { + const localStorage: KeyStorage = { + load: (keyName) => { + const item = storage.getItem(keyName); + return Promise.resolve(item); + }, + remove: (keyName) => Promise.resolve(storage.removeItem(keyName)), + store: (keyName: string, value: string): Promise => { + storage.setItem(keyName, value); + return Promise.resolve(); + }, + }; return new WebE2EE(localStorage, keyService); } } diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index 61e5a8453d8bd..54ae2293f2cb9 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -11,6 +11,7 @@ "rules": { "oxc/no-map-spread": "error", "oxc/no-async-await": "allow", - "eslint/id-length": "allow" + "eslint/id-length": "allow", + "unicorn/prefer-code-point": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/src/__tests__/vector.test.ts b/packages/e2ee/src/__tests__/vector.test.ts index 10a52854d47cb..3e2493a39e3d4 100644 --- a/packages/e2ee/src/__tests__/vector.test.ts +++ b/packages/e2ee/src/__tests__/vector.test.ts @@ -46,6 +46,6 @@ test('joinVectorAndEncryptedData with invalid vector length', () => { test('splitVectorAndEncryptedData with invalid cipherText length', () => { expect(() => splitVectorAndEncryptedData(new Uint8Array([1, 2, 3]))).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid cipherText length: 3]`, + `[Error: Invalid cipherText length]`, ); }); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 263c8b297c18f..3d230fa7b5a85 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,4 +1,4 @@ -import { type Optional, type Result, err, ok } from './utils.ts'; +import { type Result, err, ok } from './result.ts'; import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; import { toArrayBuffer, toString } from './binary.ts'; import type { BaseKeyCodec } from './codec.ts'; @@ -6,9 +6,9 @@ import type { BaseKeyCodec } from './codec.ts'; export { BaseKeyCodec } from './codec.ts'; export interface KeyStorage { - load(keyName: string): Promise; + load(keyName: string): Promise; store(keyName: string, value: string): Promise; - remove(keyName: string): Promise; + remove(keyName: string): Promise; } export interface KeyService { @@ -24,6 +24,11 @@ export interface KeyPair { private_key: PrivateKey; } +export interface LocalKeyPair { + public_key: PublicKey | null | undefined; + private_key: PublicKey | null | undefined; +} + export abstract class BaseE2EE { #service: KeyService; #storage: KeyStorage; @@ -44,13 +49,11 @@ export abstract class BaseE2EE { callbacks: { onSuccess: (privateKey: CryptoKey) => void; onError: (error: unknown) => void; - parse: (data: string) => object; }, ): Promise { await this.#storage.store('public_key', public_key); try { - callbacks.onSuccess(await this.codec.crypto.importRsaDecryptKey(callbacks.parse(private_key))); - + callbacks.onSuccess(await this.#codec.crypto.importRsaDecryptKey(JSON.parse(private_key))); await this.#storage.store('private_key', private_key); } catch (error) { callbacks.onError(error); @@ -176,7 +179,7 @@ export abstract class BaseE2EE { } } - async getKeysFromLocalStorage(): Promise> { + async getKeysFromLocalStorage(): Promise { const public_key = await this.#storage.load('public_key'); const private_key = await this.#storage.load('private_key'); return { @@ -204,11 +207,11 @@ export abstract class BaseE2EE { await this.#storage.store('e2e.random_password', randomPassword); } - getRandomPassword(): Promise { + getRandomPassword(): Promise { return this.#storage.load('e2e.random_password'); } - removeRandomPassword(): Promise { + removeRandomPassword(): Promise { return this.#storage.remove('e2e.random_password'); } async createRandomPassword(length: number): Promise { diff --git a/packages/e2ee/src/utils.ts b/packages/e2ee/src/result.ts similarity index 87% rename from packages/e2ee/src/utils.ts rename to packages/e2ee/src/result.ts index 0cd002d6daaca..630b2a6fcb40e 100644 --- a/packages/e2ee/src/utils.ts +++ b/packages/e2ee/src/result.ts @@ -13,6 +13,7 @@ export interface Err { } export type Result = Ok | Err; +export type AsyncResult = Promise>; export const ok = (value: V): Ok => ({ isOk: true, value }); diff --git a/yarn.lock b/yarn.lock index a37bf505c8dd1..1ec282d06a9bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7430,6 +7430,8 @@ __metadata: dependencies: "@rocket.chat/e2ee": "workspace:*" "@vitest/browser": "npm:4.0.0-beta.8" + oxlint: "npm:~1.12.0" + oxlint-tsgolint: "npm:~0.0.4" playwright: "npm:~1.54.2" typescript: "npm:~5.9.2" vitest: "npm:4.0.0-beta.8" From ba49640fe04c1e6774321fbb1c4f4546d0147b25 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 13:58:15 -0300 Subject: [PATCH 043/251] load keys from db --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 24 +++++++++---------- packages/e2ee/src/index.ts | 8 +++---- packages/e2ee/src/result.ts | 4 ---- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 52e7f352c9bcc..268524d877c18 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -442,19 +442,17 @@ class E2E extends Emitter<{ async loadKeysFromDB(): Promise { this.setState('LOADING_KEYS'); - await this.e2ee.loadKeysFromDB({ - onSuccess: ({ public_key, private_key }) => { - this.db_public_key = public_key; - this.db_private_key = private_key; - }, - onError: (error) => { - this.setState('ERROR'); - this.error('Error fetching RSA keys from DB: ', error); - // Stop any process since we can't communicate with the server - // to get the keys. This prevents new key generation - throw error; - }, - }); + const result = await this.e2ee.loadKeysFromDB(); + if (!result.isOk) { + this.setState('ERROR'); + this.error('Error fetching RSA keys from DB: ', result.error); + // Stop any process since we can't communicate with the server + // to get the keys. This prevents new key generation + throw result.error; + } + + this.db_public_key = result.value.public_key; + this.db_private_key = result.value.private_key; } async loadKeys(keys: { public_key: string; private_key: string }): Promise { diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 3d230fa7b5a85..9dd18e3da7b21 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,4 +1,4 @@ -import { type Result, err, ok } from './result.ts'; +import { type AsyncResult, type Result, err, ok } from './result.ts'; import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; import { toArrayBuffer, toString } from './binary.ts'; import type { BaseKeyCodec } from './codec.ts'; @@ -89,11 +89,11 @@ export abstract class BaseE2EE { } } - async loadKeysFromDB(callbacks: { onSuccess: (keys: KeyPair) => void; onError: (error: unknown) => void }): Promise { + async loadKeysFromDB(): AsyncResult { try { - callbacks.onSuccess(await this.getKeysFromService()); + return ok(await this.getKeysFromService()); } catch (error) { - callbacks.onError(error); + return err(new Error('Error loading keys from service', { cause: error })); } } diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts index 630b2a6fcb40e..bdc5bcc08b49f 100644 --- a/packages/e2ee/src/result.ts +++ b/packages/e2ee/src/result.ts @@ -1,7 +1,3 @@ -export type Optional = { - [Key in keyof Obj]: Obj[Key] | null; -}; - export interface Ok { isOk: true; value: V; From 3b334c5570134c6b5ec322923eeab1fd37a8766a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 14:15:20 -0300 Subject: [PATCH 044/251] load keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 20 +++++++++---------- packages/e2ee/src/index.ts | 15 +++++--------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 268524d877c18..dc372a231789f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { LocalKeyPair } from '@rocket.chat/e2ee'; +import type { KeyPair, LocalKeyPair } from '@rocket.chat/e2ee'; import E2EE from '@rocket.chat/e2ee-web'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; @@ -455,17 +455,15 @@ class E2E extends Emitter<{ this.db_private_key = result.value.private_key; } - async loadKeys(keys: { public_key: string; private_key: string }): Promise { + async loadKeys(keys: KeyPair): Promise { this.publicKey = keys.public_key; - await this.e2ee.loadKeys(keys, { - onSuccess: (privateKey) => { - this.privateKey = privateKey; - }, - onError: (error) => { - this.setState('ERROR'); - this.error('Error loading keys: ', error); - }, - }); + const res = await this.e2ee.loadKeys(keys); + if (!res.isOk) { + this.setState('ERROR'); + this.error('Error loading keys: ', res.error); + return; + } + this.privateKey = res.value; } async createAndLoadKeys(): Promise { diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 9dd18e3da7b21..7a901e9308aed 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -44,19 +44,14 @@ export abstract class BaseE2EE { this.#codec = codec; } - async loadKeys( - { public_key, private_key }: { public_key: string; private_key: string }, - callbacks: { - onSuccess: (privateKey: CryptoKey) => void; - onError: (error: unknown) => void; - }, - ): Promise { - await this.#storage.store('public_key', public_key); + async loadKeys({ public_key, private_key }: KeyPair): AsyncResult { try { - callbacks.onSuccess(await this.#codec.crypto.importRsaDecryptKey(JSON.parse(private_key))); + const res = await this.#codec.crypto.importRsaDecryptKey(JSON.parse(private_key)); + await this.#storage.store('public_key', public_key); await this.#storage.store('private_key', private_key); + return ok(res); } catch (error) { - callbacks.onError(error); + return err(new Error('Error loading keys', { cause: error })); } } From 031a30128ab76a7c20e73ebf4a829c27bee08e46 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 14:34:06 -0300 Subject: [PATCH 045/251] simplify --- apps/meteor/client/lib/e2ee/helper.ts | 4 ++-- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 8 ++++---- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index fdb58476c5dc6..3d51d9bc68bd9 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -85,9 +85,9 @@ export async function exportJWKKey(key: CryptoKey): Promise { return crypto.subtle.exportKey('jwk', key); } -export async function importRSAKey(keyData: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { +export async function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { return crypto.subtle.importKey( - 'jwk' as any, + 'jwk', keyData, { name: 'RSA-OAEP', diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 69732d0cb859f..0ebca6e68f4d7 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -490,7 +490,7 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(JSON.parse(user.e2e!.public_key!)); if (!encryptedGroupKey) { return; } @@ -540,10 +540,10 @@ export class E2ERoom extends Emitter { } } - async encryptGroupKeyForParticipant(publicKey: string) { + async encryptGroupKeyForParticipant(publicKey: JsonWebKey) { let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await importRSAKey(publicKey, ['encrypt']); } catch (error) { return this.error('Error importing user key: ', error); } @@ -772,7 +772,7 @@ export class E2ERoom extends Emitter { const usersWithKeys = await Promise.all( users.map(async (user) => { const { _id, public_key } = user; - const key = await this.encryptGroupKeyForParticipant(public_key); + const key = await this.encryptGroupKeyForParticipant(JSON.parse(public_key)); if (decryptedOldGroupKeys) { const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys); return { _id, key, oldKeys }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index dc372a231789f..f2a34a5d1ec3c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -56,7 +56,7 @@ class E2E extends Emitter<{ public privateKey: CryptoKey | undefined; - public publicKey: string | undefined; + public publicKey: JsonWebKey | undefined; private keyDistributionInterval: ReturnType | null; @@ -456,7 +456,7 @@ class E2E extends Emitter<{ } async loadKeys(keys: KeyPair): Promise { - this.publicKey = keys.public_key; + this.publicKey = JSON.parse(keys.public_key); const res = await this.e2ee.loadKeys(keys); if (!res.isOk) { this.setState('ERROR'); @@ -474,7 +474,7 @@ class E2E extends Emitter<{ this.privateKey = privateKey; }, onPublicKey: (publicKey) => { - this.publicKey = JSON.stringify(publicKey); + this.publicKey = publicKey; }, onError: (error) => { this.setState('ERROR'); From d8bce3c9269147a98ab8cc65190cd1b2360b520f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 15:04:05 -0300 Subject: [PATCH 046/251] getMasterKey --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 11 --- packages/e2ee-node/README.md | 98 ------------------- packages/e2ee/README.md | 98 ------------------- packages/e2ee/src/index.ts | 8 +- packages/e2ee/src/result.ts | 3 +- 5 files changed, 5 insertions(+), 213 deletions(-) delete mode 100644 packages/e2ee-node/README.md delete mode 100644 packages/e2ee/README.md diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index f2a34a5d1ec3c..04ee76941c018 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -502,17 +502,6 @@ class E2E extends Emitter<{ return this.error('Error encoding private key: ', res.error); } - async getMasterKey(password: string): Promise { - const res = await this.e2ee.getMasterKey(password); - - if (res.isOk) { - return res.value; - } - - this.setState('ERROR'); - return this.error('Error getting master key: ', res.error); - } - openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { imperativeModal.open({ component: EnterE2EPasswordModal, diff --git a/packages/e2ee-node/README.md b/packages/e2ee-node/README.md deleted file mode 100644 index 46e1e13e99b36..0000000000000 --- a/packages/e2ee-node/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# @rocket.chat/e2e-crypto-core - -Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: - -- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) -- Double Ratchet skeleton for forward secrecy -- Group key derivation & rotation helpers -- Message serialization & integrity (MAC) helpers -- Replay protection utilities -- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage - -> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. - -## Design Principles - -1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. -2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. -3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). -4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. -5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. - -## Core Modules - -| Module | Purpose | -| ------ | ------- | -| `providers/interfaces.ts` | Abstract provider contracts | -| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | -| `core/keyManagement.ts` | Identity & group key helpers | -| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | -| `core/messageIntegrity.ts` | MAC computation / verification | -| `core/replayProtection.ts` | Replay cache + ID helpers | -| `protocol/session.ts` | High-level session manager wrapper | - -## Provider Interfaces - -Implement and inject: - -```ts -import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; - -const ctx: ProviderContext = { - crypto: /* your CryptoProvider */, - rng: /* RandomnessProvider */, - hash: /* HashProvider */, - storage: /* KeyStorageProvider */, - net: /* optional NetworkProvider */, -}; -``` - -## Basic Usage (1:1 Session) - -```ts -import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; - -// ctx: ProviderContext injected -const keyMgr = new KeyManager(ctx); -await keyMgr.ensureIdentityKey(); - -// Assume we fetched peer pre-key bundle externally -const sessionMgr = new SessionManager(ctx); -const init = { - theirIdentityKey: peerBundle.identityKey, - theirSignedPreKey: peerBundle.signedPreKey, - theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], - isInitiator: true, -}; - -// Encrypt -const plaintext = new TextEncoder().encode('hello'); -const payload = await sessionMgr.encrypt('peer-user-id', plaintext); -const wire = serializePayload(payload); // send over DDP / WebSocket / REST - -// Decrypt (other side) -const received = deserializePayload(wire); -const result = await sessionMgr.decrypt('peer-user-id', received); -console.log(new TextDecoder().decode(result.plaintext)); -``` - -## Group Messaging (Conceptual) - -Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. - -## Error Handling - -All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. - -## Extending / Hardening - -Production needs (out of scope of this skeleton): -- Skipped message key handling & storage -- Full DH ratchet step logic & header validation -- Signature verification of pre-key bundles -- Authentic secondary MAC with independent key material -- More robust replay filtering (e.g. bloom filter) -- Formal test vectors & interoperability tests - -## License -MIT diff --git a/packages/e2ee/README.md b/packages/e2ee/README.md deleted file mode 100644 index 46e1e13e99b36..0000000000000 --- a/packages/e2ee/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# @rocket.chat/e2e-crypto-core - -Runtime-agnostic, **sans-IO** end-to-end encryption core for Rocket.Chat providing: - -- Pluggable provider interfaces (crypto, randomness, hashing, storage, network) -- Double Ratchet skeleton for forward secrecy -- Group key derivation & rotation helpers -- Message serialization & integrity (MAC) helpers -- Replay protection utilities -- Strict TypeScript types; no direct I/O, DOM, Node, or platform API usage - -> NOTE: This package deliberately avoids binding to WebCrypto / Node crypto; you must supply providers. - -## Design Principles - -1. **Sans-IO**: All side-effects (key persistence, network, randomness, system crypto) are injected. -2. **Agnostic**: Works in browsers, Node.js, React Native, workers—given compatible providers. -3. **Composability**: Small focused modules (ratchet, key mgmt, integrity, replay, codec). -4. **Explicit Types**: Strong typing for every boundary; opaque `Uint8Array` for binary. -5. **Security First**: Forward secrecy (Double Ratchet), AEAD (AES-256-GCM or ChaCha20-Poly1305 via provider), MAC utilities, replay cache hooks. - -## Core Modules - -| Module | Purpose | -| ------ | ------- | -| `providers/interfaces.ts` | Abstract provider contracts | -| `core/doubleRatchet.ts` | Ratchet state machine (simplified) | -| `core/keyManagement.ts` | Identity & group key helpers | -| `core/messageCodec.ts` | Canonical (JSON/Base64) serialization | -| `core/messageIntegrity.ts` | MAC computation / verification | -| `core/replayProtection.ts` | Replay cache + ID helpers | -| `protocol/session.ts` | High-level session manager wrapper | - -## Provider Interfaces - -Implement and inject: - -```ts -import { ProviderContext } from '@rocket.chat/e2e-crypto-core'; - -const ctx: ProviderContext = { - crypto: /* your CryptoProvider */, - rng: /* RandomnessProvider */, - hash: /* HashProvider */, - storage: /* KeyStorageProvider */, - net: /* optional NetworkProvider */, -}; -``` - -## Basic Usage (1:1 Session) - -```ts -import { SessionManager, KeyManager, serializePayload, deserializePayload } from '@rocket.chat/e2e-crypto-core'; - -// ctx: ProviderContext injected -const keyMgr = new KeyManager(ctx); -await keyMgr.ensureIdentityKey(); - -// Assume we fetched peer pre-key bundle externally -const sessionMgr = new SessionManager(ctx); -const init = { - theirIdentityKey: peerBundle.identityKey, - theirSignedPreKey: peerBundle.signedPreKey, - theirOneTimePreKey: peerBundle.oneTimePreKeys?.[0], - isInitiator: true, -}; - -// Encrypt -const plaintext = new TextEncoder().encode('hello'); -const payload = await sessionMgr.encrypt('peer-user-id', plaintext); -const wire = serializePayload(payload); // send over DDP / WebSocket / REST - -// Decrypt (other side) -const received = deserializePayload(wire); -const result = await sessionMgr.decrypt('peer-user-id', received); -console.log(new TextDecoder().decode(result.plaintext)); -``` - -## Group Messaging (Conceptual) - -Use `GroupKeyManager` to rotate epoch keys; wrap `EncryptedPayload` in `serializeGroupEnvelope`. - -## Error Handling - -All protocol-specific errors throw `ProtocolError` with a `code` field for switch handling. - -## Extending / Hardening - -Production needs (out of scope of this skeleton): -- Skipped message key handling & storage -- Full DH ratchet step logic & header validation -- Signature verification of pre-key bundles -- Authentic secondary MAC with independent key material -- More robust replay filtering (e.g. bloom filter) -- Formal test vectors & interoperability tests - -## License -MIT diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 7a901e9308aed..8d513bb86e212 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,4 +1,4 @@ -import { type AsyncResult, type Result, err, ok } from './result.ts'; +import { type AsyncResult, err, ok } from './result.ts'; import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; import { toArrayBuffer, toString } from './binary.ts'; import type { BaseKeyCodec } from './codec.ts'; @@ -92,7 +92,7 @@ export abstract class BaseE2EE { } } - async encodePrivateKey(privateKey: string, password: string): Promise> { + async encodePrivateKey(privateKey: string, password: string): AsyncResult { const masterKey = await this.getMasterKey(password); if (!masterKey.isOk) { return masterKey; @@ -108,7 +108,7 @@ export abstract class BaseE2EE { } } - async decodePrivateKey(privateKey: string, password: string): Promise> { + async decodePrivateKey(privateKey: string, password: string): AsyncResult { const masterKey = await this.getMasterKey(password); if (!masterKey.isOk) { return masterKey; @@ -142,7 +142,7 @@ export abstract class BaseE2EE { return this.#storage.store('private_key', stringified); } - async getMasterKey(password: string): Promise> { + async getMasterKey(password: string): AsyncResult { if (!password) { return err(new Error('You should provide a password')); } diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts index bdc5bcc08b49f..a9301ab604c6b 100644 --- a/packages/e2ee/src/result.ts +++ b/packages/e2ee/src/result.ts @@ -12,5 +12,4 @@ export type Result = Ok | Err; export type AsyncResult = Promise>; export const ok = (value: V): Ok => ({ isOk: true, value }); - -export const err = (error: E): Err => ({ error }); +export const err = (error: E): Err => ({ error }); \ No newline at end of file From 460d8319438af1e153b5bd05f014fb4cffc522ab Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 22 Aug 2025 17:01:22 -0300 Subject: [PATCH 047/251] create and load keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 25 ++++----- .../e2ee-node/src/__tests__/.oxlintrc.json | 5 ++ packages/e2ee-node/src/__tests__/e2ee.test.ts | 25 ++++----- packages/e2ee-web/.oxlintrc.json | 1 - .../e2ee-web/src/__tests__/.oxlintrc.json | 5 ++ packages/e2ee-web/src/__tests__/codec.test.ts | 2 +- packages/e2ee-web/src/__tests__/e2ee.test.ts | 27 ++++------ packages/e2ee-web/src/index.ts | 2 +- packages/e2ee/src/index.ts | 53 +++++++++---------- 9 files changed, 68 insertions(+), 77 deletions(-) create mode 100644 packages/e2ee-node/src/__tests__/.oxlintrc.json create mode 100644 packages/e2ee-web/src/__tests__/.oxlintrc.json diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 04ee76941c018..60ca1ad2d124c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { KeyPair, LocalKeyPair } from '@rocket.chat/e2ee'; +import type { LocalKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; import E2EE from '@rocket.chat/e2ee-web'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; @@ -455,7 +455,7 @@ class E2E extends Emitter<{ this.db_private_key = result.value.private_key; } - async loadKeys(keys: KeyPair): Promise { + async loadKeys(keys: RemoteKeyPair): Promise { this.publicKey = JSON.parse(keys.public_key); const res = await this.e2ee.loadKeys(keys); if (!res.isOk) { @@ -469,23 +469,16 @@ class E2E extends Emitter<{ async createAndLoadKeys(): Promise { // Could not obtain public-private keypair from server. this.setState('LOADING_KEYS'); - await this.e2ee.createAndLoadKeys({ - onPrivateKey: (privateKey) => { - this.privateKey = privateKey; - }, - onPublicKey: (publicKey) => { - this.publicKey = publicKey; - }, - onError: (error) => { - this.setState('ERROR'); - this.error('Error creating keys: ', error); - }, - }); + const keys = await this.e2ee.createAndLoadKeys(); - if (this.getState() === 'ERROR') { - return; + if (!keys.isOk) { + this.setState('ERROR'); + return this.error('Error creating keys: ', keys.error); } + this.publicKey = keys.value.publicKey; + this.privateKey = keys.value.privateKey; + await this.requestSubscriptionKeys(); } diff --git a/packages/e2ee-node/src/__tests__/.oxlintrc.json b/packages/e2ee-node/src/__tests__/.oxlintrc.json new file mode 100644 index 0000000000000..329fae1135eb5 --- /dev/null +++ b/packages/e2ee-node/src/__tests__/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-magic-numbers": "allow" + } +} \ No newline at end of file diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index a6db53d9ff438..28d26bdc07e4f 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -1,27 +1,22 @@ -import { test, assert } from 'vitest'; - +import { expect, test } from 'vitest'; import E2EE from '../index.ts'; -import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; +import type { KeyService } from '@rocket.chat/e2ee'; -class MockedKeyService implements KeyService { - userId() { - return Promise.resolve('mocked_user_id'); - } - fetchMyKeys(): Promise { - return Promise.resolve({ +const mockedKeyService: KeyService = { + fetchMyKeys: () => + Promise.resolve({ public_key: 'mocked_public_key', private_key: 'mocked_private_key', - }); - } -} + }), + userId: () => Promise.resolve('mocked_user_id'), +}; test('E2EE createRandomPassword deterministic generation with 5 words', async () => { // Inject custom word list by temporarily defining dynamic import. // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. - const mockedKeyService = new MockedKeyService(); const e2ee = E2EE.withMemoryStorage(mockedKeyService); const pwd = await e2ee.createRandomPassword(5); - assert.equal(pwd.split(' ').length, 5); - assert.equal(await e2ee.getRandomPassword(), pwd); + expect(pwd.split(' ').length).toBe(5); + expect(await e2ee.getRandomPassword()).toBe(pwd); }); diff --git a/packages/e2ee-web/.oxlintrc.json b/packages/e2ee-web/.oxlintrc.json index fb5354c3f45de..d9b127d3e1971 100644 --- a/packages/e2ee-web/.oxlintrc.json +++ b/packages/e2ee-web/.oxlintrc.json @@ -17,6 +17,5 @@ "eslint/no-plusplus": "allow", "eslint/no-magic-numbers": "allow", "unicorn/prefer-code-point": "allow" - } } \ No newline at end of file diff --git a/packages/e2ee-web/src/__tests__/.oxlintrc.json b/packages/e2ee-web/src/__tests__/.oxlintrc.json new file mode 100644 index 0000000000000..329fae1135eb5 --- /dev/null +++ b/packages/e2ee-web/src/__tests__/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-magic-numbers": "allow" + } +} \ No newline at end of file diff --git a/packages/e2ee-web/src/__tests__/codec.test.ts b/packages/e2ee-web/src/__tests__/codec.test.ts index 84b3c0aa559c3..496d06bc988ef 100644 --- a/packages/e2ee-web/src/__tests__/codec.test.ts +++ b/packages/e2ee-web/src/__tests__/codec.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from 'vitest'; +import { expect, test } from 'vitest'; import KeyCodec from '../codec.ts'; const codec = new KeyCodec(); diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts index 124b85f4c172f..28d26bdc07e4f 100644 --- a/packages/e2ee-web/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-web/src/__tests__/e2ee.test.ts @@ -1,27 +1,22 @@ -import { test, assert } from 'vitest'; - +import { expect, test } from 'vitest'; import E2EE from '../index.ts'; -import type { KeyPair, KeyService } from '@rocket.chat/e2ee'; +import type { KeyService } from '@rocket.chat/e2ee'; -class MockedKeyService implements KeyService { - userId() { - return Promise.resolve('mocked_user_id'); - } - fetchMyKeys(): Promise { - return Promise.resolve({ +const mockedKeyService: KeyService = { + fetchMyKeys: () => + Promise.resolve({ public_key: 'mocked_public_key', private_key: 'mocked_private_key', - }); - } -} + }), + userId: () => Promise.resolve('mocked_user_id'), +}; test('E2EE createRandomPassword deterministic generation with 5 words', async () => { // Inject custom word list by temporarily defining dynamic import. // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. - const mockedKeyService = new MockedKeyService(); - const e2ee = E2EE.withLocalStorage(mockedKeyService); + const e2ee = E2EE.withMemoryStorage(mockedKeyService); const pwd = await e2ee.createRandomPassword(5); - assert.equal(pwd.split(' ').length, 5); - assert.equal(await e2ee.getRandomPassword(), pwd); + expect(pwd.split(' ').length).toBe(5); + expect(await e2ee.getRandomPassword()).toBe(pwd); }); diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts index 218a41365d0c4..5c71426714dd8 100644 --- a/packages/e2ee-web/src/index.ts +++ b/packages/e2ee-web/src/index.ts @@ -19,7 +19,7 @@ export default class WebE2EE extends BaseE2EE { return new WebE2EE(memoryStorage, keyService); } - static withLocalStorage(keyService: KeyService, storage: Storage = globalThis.localStorage): WebE2EE { + static withLocalStorage(keyService: KeyService, storage: Storage): WebE2EE { const localStorage: KeyStorage = { load: (keyName) => { const item = storage.getItem(keyName); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 8d513bb86e212..21f861c86eca3 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -13,20 +13,25 @@ export interface KeyStorage { export interface KeyService { userId: () => Promise; - fetchMyKeys: () => Promise; + fetchMyKeys: () => Promise; } export type PrivateKey = string; export type PublicKey = string; -export interface KeyPair { - public_key: PublicKey; - private_key: PrivateKey; +export interface RemoteKeyPair { + public_key: string; + private_key: string; } export interface LocalKeyPair { - public_key: PublicKey | null | undefined; - private_key: PublicKey | null | undefined; + public_key: string | null | undefined; + private_key: string | null | undefined; +} + +export interface KeyPair { + privateKey: CryptoKey; + publicKey: JsonWebKey; } export abstract class BaseE2EE { @@ -44,7 +49,7 @@ export abstract class BaseE2EE { this.#codec = codec; } - async loadKeys({ public_key, private_key }: KeyPair): AsyncResult { + async loadKeys({ public_key, private_key }: RemoteKeyPair): AsyncResult { try { const res = await this.#codec.crypto.importRsaDecryptKey(JSON.parse(private_key)); await this.#storage.store('public_key', public_key); @@ -55,36 +60,29 @@ export abstract class BaseE2EE { } } - async createAndLoadKeys(callbacks: { - onPrivateKey: (privateKey: CryptoKey) => void; - onPublicKey: (publicKey: JsonWebKey) => void; - onError: (error: unknown) => void; - }): Promise { - // Could not obtain public-private keypair from server. + async createAndLoadKeys(): AsyncResult { try { const keys = await this.#codec.crypto.generateRsaOaepKeyPair(); - callbacks.onPrivateKey(keys.privateKey); try { - await this.setPublicKey(keys.publicKey); - const publicKey = await this.#codec.crypto.exportJsonWebKey(keys.publicKey); - callbacks.onPublicKey(publicKey); + const publicKey = await this.setPublicKey(keys.publicKey); try { await this.setPrivateKey(keys.privateKey); + return ok({ + privateKey: keys.privateKey, + publicKey, + }); } catch (error) { - callbacks.onError(error); - return; + return err(new Error('Error setting private key', { cause: error })); } } catch (error) { - callbacks.onError(error); - return; + return err(new Error('Error setting public key', { cause: error })); } } catch (error) { - callbacks.onError(error); - return; + return err(new Error('Error creating and loading keys', { cause: error })); } } - async loadKeysFromDB(): AsyncResult { + async loadKeysFromDB(): AsyncResult { try { return ok(await this.getKeysFromService()); } catch (error) { @@ -130,10 +128,11 @@ export abstract class BaseE2EE { * Sets the public key in the storage. * @param key The public key to store. */ - async setPublicKey(key: CryptoKey): Promise { + async setPublicKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); - return this.#storage.store('public_key', stringified); + await this.#storage.store('public_key', stringified); + return exported; } async setPrivateKey(key: CryptoKey): Promise { @@ -188,7 +187,7 @@ export abstract class BaseE2EE { await this.#storage.remove('private_key'); } - async getKeysFromService(): Promise { + async getKeysFromService(): Promise { const keys = await this.#service.fetchMyKeys(); if (!keys) { From 012026cb788c6af555fe96a469f4f48dcee2a592 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 23 Aug 2025 12:40:51 -0300 Subject: [PATCH 048/251] persist keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 42 ++++++++----------- packages/e2ee/src/index.ts | 22 ++++++++++ 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 60ca1ad2d124c..9d3a57332ecf2 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -73,6 +73,11 @@ class E2E extends Emitter<{ { userId: () => Promise.resolve(Meteor.userId()), fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), + persistKeys: (keys, force) => + sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { + ...keys, + force, + }), }, Accounts.storageLocation, ); @@ -218,6 +223,9 @@ class E2E extends Emitter<{ this.emit(nextState); } + /** + * Handles the suggested E2E key for a subscription. + */ async handleAsyncE2EESuggestedKey() { const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( @@ -293,26 +301,11 @@ class E2E extends Emitter<{ delete this.instancesByRoomId[rid]; } - private async persistKeys( - { public_key, private_key }: LocalKeyPair, - password: string, - { force }: { force: boolean } = { force: false }, - ): Promise { - if (typeof public_key !== 'string' || typeof private_key !== 'string') { - throw new Error('Failed to persist keys as they are not strings.'); - } - - const encodedPrivateKey = await this.encodePrivateKey(private_key, password); - - if (!encodedPrivateKey) { - throw new Error('Failed to encode private key with provided password.'); + private async persistKeys(localKeyPair: LocalKeyPair, password: string, force = false): Promise { + const res = await this.e2ee.persistKeys(localKeyPair, password, force); + if (!res.isOk) { + throw res.error; } - - await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { - public_key, - private_key: encodedPrivateKey, - force, - }); } async acceptSuggestedKey(rid: string): Promise { @@ -363,7 +356,9 @@ class E2E extends Emitter<{ let { public_key, private_key } = await this.e2ee.getKeysFromLocalStorage(); - await this.loadKeysFromDB(); + const dbKeys = await this.loadKeysFromDB(); + this.db_private_key = dbKeys.private_key; + this.db_public_key = dbKeys.public_key; if (!public_key && this.db_public_key) { public_key = this.db_public_key; @@ -433,14 +428,14 @@ class E2E extends Emitter<{ } async changePassword(newPassword: string): Promise { - await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), newPassword, { force: true }); + await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), newPassword, true); if (await this.e2ee.getRandomPassword()) { await this.e2ee.storeRandomPassword(newPassword); } } - async loadKeysFromDB(): Promise { + async loadKeysFromDB(): Promise { this.setState('LOADING_KEYS'); const result = await this.e2ee.loadKeysFromDB(); if (!result.isOk) { @@ -451,8 +446,7 @@ class E2E extends Emitter<{ throw result.error; } - this.db_public_key = result.value.public_key; - this.db_private_key = result.value.private_key; + return result.value; } async loadKeys(keys: RemoteKeyPair): Promise { diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 21f861c86eca3..a354d1ffb8e0a 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -14,6 +14,7 @@ export interface KeyStorage { export interface KeyService { userId: () => Promise; fetchMyKeys: () => Promise; + persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; } export type PrivateKey = string; @@ -90,6 +91,27 @@ export abstract class BaseE2EE { } } + async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { + if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { + return err(new TypeError('Failed to persist keys as they are not strings.')); + } + + const encodedPrivateKey = await this.encodePrivateKey(localKeyPair.private_key, password); + if (!encodedPrivateKey.isOk) { + return encodedPrivateKey; + } + + return ok( + await this.#service.persistKeys( + { + private_key: encodedPrivateKey.value, + public_key: localKeyPair.public_key, + }, + force, + ), + ); + } + async encodePrivateKey(privateKey: string, password: string): AsyncResult { const masterKey = await this.getMasterKey(password); if (!masterKey.isOk) { From d7ca001d9e136ab341deb61ddae7c49d790aa5ce Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 23 Aug 2025 13:15:59 -0300 Subject: [PATCH 049/251] fix types --- packages/e2ee-node/src/__tests__/e2ee.test.ts | 1 + packages/e2ee-web/src/__tests__/e2ee.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee-node/src/__tests__/e2ee.test.ts index 28d26bdc07e4f..919ca42251239 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-node/src/__tests__/e2ee.test.ts @@ -9,6 +9,7 @@ const mockedKeyService: KeyService = { private_key: 'mocked_private_key', }), userId: () => Promise.resolve('mocked_user_id'), + persistKeys: () => Promise.resolve(), }; test('E2EE createRandomPassword deterministic generation with 5 words', async () => { diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts index 28d26bdc07e4f..919ca42251239 100644 --- a/packages/e2ee-web/src/__tests__/e2ee.test.ts +++ b/packages/e2ee-web/src/__tests__/e2ee.test.ts @@ -9,6 +9,7 @@ const mockedKeyService: KeyService = { private_key: 'mocked_private_key', }), userId: () => Promise.resolve('mocked_user_id'), + persistKeys: () => Promise.resolve(), }; test('E2EE createRandomPassword deterministic generation with 5 words', async () => { From c4f3e39e2375bc25a26e95d5428fcea7eedf8903 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 24 Aug 2025 16:31:09 -0300 Subject: [PATCH 050/251] remove codec accessor --- packages/e2ee/src/index.ts | 4 ---- packages/omni-core/package.json | 3 --- yarn.lock | 5 +++++ 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index a354d1ffb8e0a..5a6b9a81a041b 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -40,10 +40,6 @@ export abstract class BaseE2EE { #storage: KeyStorage; #codec: BaseKeyCodec; - get codec(): BaseKeyCodec { - return this.#codec; - } - constructor(codec: BaseKeyCodec, storage: KeyStorage, service: KeyService) { this.#storage = storage; this.#service = service; diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index a66939f6fbed7..75941adc90cf4 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -32,8 +32,5 @@ "@rocket.chat/models": "workspace:^", "@rocket.chat/patch-injection": "workspace:^", "mongodb": "6.10.0" - }, - "volta": { - "extends": "../../package.json" } } diff --git a/yarn.lock b/yarn.lock index 1ec282d06a9bf..2d1336f467748 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8560,14 +8560,18 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/omni-core-ee@workspace:ee/packages/omni-core-ee" dependencies: + "@rocket.chat/core-services": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/logger": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/omni-core": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" + mem: "npm:^8.1.1" + mongodb: "npm:6.10.0" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -8585,6 +8589,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" + mongodb: "npm:6.10.0" typescript: "npm:~5.9.2" languageName: unknown linkType: soft From 98ddf075b61f1dc07fa0f99af33294e5fb2c90bf Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 13:29:14 -0300 Subject: [PATCH 051/251] use literal type for room state --- .../hooks/roomActions/useE2EERoomAction.ts | 11 ++- apps/meteor/client/lib/e2ee/E2ERoomState.ts | 23 +++---- .../client/lib/e2ee/rocketchat.e2e.room.ts | 67 +++++++++---------- .../views/room/E2EESetup/RoomE2EESetup.tsx | 3 +- .../views/room/Header/RoomHeaderE2EESetup.tsx | 3 +- .../room/HeaderV2/RoomHeaderE2EESetup.tsx | 3 +- .../messageBox/MessageBoxHint.spec.tsx | 7 +- .../composer/messageBox/MessageBoxHint.tsx | 5 +- .../hooks/useChatMessagesInstance.spec.ts | 6 +- packages/e2ee/docs/NOTES.md | 30 +++++++++ 10 files changed, 87 insertions(+), 71 deletions(-) create mode 100644 packages/e2ee/docs/NOTES.md diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index 346787523f9b6..ffe39ff47e987 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -6,7 +6,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { OtrRoomState } from '../../../app/otr/lib/OtrRoomState'; -import { E2ERoomState } from '../../lib/e2ee/E2ERoomState'; import { getRoomTypeTranslation } from '../../lib/getRoomTypeTranslation'; import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; @@ -34,11 +33,11 @@ export const useE2EERoomAction = () => { const isE2EERoomNotReady = () => { if ( - e2eeRoomState === E2ERoomState.NO_PASSWORD_SET || - e2eeRoomState === E2ERoomState.NOT_STARTED || - e2eeRoomState === E2ERoomState.DISABLED || - e2eeRoomState === E2ERoomState.ERROR || - e2eeRoomState === E2ERoomState.WAITING_KEYS + e2eeRoomState === 'NO_PASSWORD_SET' || + e2eeRoomState === 'NOT_STARTED' || + e2eeRoomState === 'DISABLED' || + e2eeRoomState === 'ERROR' || + e2eeRoomState === 'WAITING_KEYS' ) { return true; } diff --git a/apps/meteor/client/lib/e2ee/E2ERoomState.ts b/apps/meteor/client/lib/e2ee/E2ERoomState.ts index 5e060816436e6..e084d0b6d9a07 100644 --- a/apps/meteor/client/lib/e2ee/E2ERoomState.ts +++ b/apps/meteor/client/lib/e2ee/E2ERoomState.ts @@ -1,12 +1,11 @@ -export enum E2ERoomState { - NO_PASSWORD_SET = 'NO_PASSWORD_SET', - NOT_STARTED = 'NOT_STARTED', - DISABLED = 'DISABLED', - HANDSHAKE = 'HANDSHAKE', - ESTABLISHING = 'ESTABLISHING', - CREATING_KEYS = 'CREATING_KEYS', - WAITING_KEYS = 'WAITING_KEYS', - KEYS_RECEIVED = 'KEYS_RECEIVED', - READY = 'READY', - ERROR = 'ERROR', -} +export type E2ERoomState = + | 'NO_PASSWORD_SET' + | 'NOT_STARTED' + | 'DISABLED' + | 'HANDSHAKE' + | 'ESTABLISHING' + | 'CREATING_KEYS' + | 'WAITING_KEYS' + | 'KEYS_RECEIVED' + | 'READY' + | 'ERROR'; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 0ebca6e68f4d7..06a774a19c2f9 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -4,7 +4,7 @@ import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; -import { E2ERoomState } from './E2ERoomState'; +import type { E2ERoomState } from './E2ERoomState'; import { toString, toArrayBuffer, @@ -36,21 +36,14 @@ import { roomCoordinator } from '../rooms/roomCoordinator'; const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { [k in keyof typeof E2ERoomState]?: (keyof typeof E2ERoomState)[] }; +type Mutations = { [k in E2ERoomState]?: E2ERoomState[] }; const permitedMutations: Mutations = { - [E2ERoomState.NOT_STARTED]: [E2ERoomState.ESTABLISHING, E2ERoomState.DISABLED, E2ERoomState.KEYS_RECEIVED], - [E2ERoomState.READY]: [E2ERoomState.DISABLED, E2ERoomState.CREATING_KEYS, E2ERoomState.WAITING_KEYS], - [E2ERoomState.ERROR]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.NOT_STARTED], - [E2ERoomState.WAITING_KEYS]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.ERROR, E2ERoomState.DISABLED], - [E2ERoomState.ESTABLISHING]: [ - E2ERoomState.READY, - E2ERoomState.KEYS_RECEIVED, - E2ERoomState.ERROR, - E2ERoomState.DISABLED, - E2ERoomState.WAITING_KEYS, - E2ERoomState.CREATING_KEYS, - ], + NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'], + READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], + ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'], + WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], + ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], }; const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { @@ -60,7 +53,7 @@ const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERo } if (currentState === nextState) { - return nextState === E2ERoomState.ERROR ? E2ERoomState.ERROR : false; + return nextState === 'ERROR' ? 'ERROR' : false; } if (!(currentState in permitedMutations)) { @@ -105,11 +98,11 @@ export class E2ERoom extends Emitter { this.typeOfRoom = room.t; this.roomKeyId = room.e2eKeyId; - this.once(E2ERoomState.READY, async () => { + this.once('READY', async () => { await this.decryptOldRoomKeys(); return this.decryptPendingMessages(); }); - this.once(E2ERoomState.READY, () => this.decryptSubscription()); + this.once('READY', () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === RoomManager.opened) { this.log(`[PREV: ${prev}]`, 'State CHANGED'); @@ -117,7 +110,7 @@ export class E2ERoom extends Emitter { }); this.on('STATE_CHANGED', () => this.handshake()); - this.setState(E2ERoomState.NOT_STARTED); + this.setState('NOT_STARTED'); } log(...msg: unknown[]) { @@ -152,23 +145,23 @@ export class E2ERoom extends Emitter { } isReady() { - return this.state === E2ERoomState.READY; + return this.state === 'READY'; } isDisabled() { - return this.state === E2ERoomState.DISABLED; + return this.state === 'DISABLED'; } enable() { - if (this.state === E2ERoomState.READY) { + if (this.state === 'READY') { return; } - this.setState(E2ERoomState.READY); + this.setState('READY'); } disable() { - this.setState(E2ERoomState.DISABLED); + this.setState('DISABLED'); } pause() { @@ -184,7 +177,7 @@ export class E2ERoom extends Emitter { } keyReceived() { - this.setState(E2ERoomState.KEYS_RECEIVED); + this.setState('KEYS_RECEIVED'); } async shouldConvertSentMessages(message: { msg: string }) { @@ -210,7 +203,7 @@ export class E2ERoom extends Emitter { } isWaitingKeys() { - return this.state === E2ERoomState.WAITING_KEYS; + return this.state === 'WAITING_KEYS'; } get keyID() { @@ -313,21 +306,21 @@ export class E2ERoom extends Emitter { return; } - if (this.state !== E2ERoomState.KEYS_RECEIVED && this.state !== E2ERoomState.NOT_STARTED) { + if (this.state !== 'KEYS_RECEIVED' && this.state !== 'NOT_STARTED') { return; } - this.setState(E2ERoomState.ESTABLISHING); + this.setState('ESTABLISHING'); try { const groupKey = Subscriptions.state.find((record) => record.rid === this.roomId)?.E2EKey; if (groupKey) { await this.importGroupKey(groupKey); - this.setState(E2ERoomState.READY); + this.setState('READY'); return; } } catch (error) { - this.setState(E2ERoomState.ERROR); + this.setState('ERROR'); this.error('Error fetching group key: ', error); return; } @@ -335,18 +328,18 @@ export class E2ERoom extends Emitter { try { const room = Rooms.state.get(this.roomId); if (!room?.e2eKeyId) { - this.setState(E2ERoomState.CREATING_KEYS); + this.setState('CREATING_KEYS'); await this.createGroupKey(); - this.setState(E2ERoomState.READY); + this.setState('READY'); return; } - this.setState(E2ERoomState.WAITING_KEYS); + this.setState('WAITING_KEYS'); this.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { // this.error = error; - this.setState(E2ERoomState.ERROR); + this.setState('ERROR'); } } @@ -444,13 +437,13 @@ export class E2ERoom extends Emitter { return; } - this.setState(E2ERoomState.CREATING_KEYS); + this.setState('CREATING_KEYS'); try { await this.createNewGroupKey(); const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; - this.setState(E2ERoomState.READY); + this.setState('READY'); this.log(`Room key reset done for room ${this.roomId}`); return e2eNewKeys; @@ -462,7 +455,7 @@ export class E2ERoom extends Emitter { onRoomKeyReset(keyID: string) { this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); - this.setState(E2ERoomState.WAITING_KEYS); + this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; this.sessionKeyExportedString = undefined; @@ -754,7 +747,7 @@ export class E2ERoom extends Emitter { } void this.encryptKeyForOtherParticipants(); - this.setState(E2ERoomState.READY); + this.setState('READY'); } onStateChange(cb: () => void) { diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index 70b3b3a550af8..d07abb40a366e 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'; import RoomE2EENotAllowed from './RoomE2EENotAllowed'; import { e2e } from '../../../lib/e2ee'; -import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import RoomBody from '../body/RoomBody'; import RoomBodyV2 from '../body/RoomBodyV2'; import { useRoom } from '../contexts/RoomContext'; @@ -55,7 +54,7 @@ const RoomE2EESetup = () => { ); } - if (e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eRoomState === 'WAITING_KEYS') { return ( { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') { return } />; } diff --git a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx index 3b9f265e838d2..061f39f6aae6c 100644 --- a/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx +++ b/apps/meteor/client/views/room/HeaderV2/RoomHeaderE2EESetup.tsx @@ -2,7 +2,6 @@ import { lazy } from 'react'; import RoomHeader from './RoomHeader'; import type { RoomHeaderProps } from './RoomHeader'; -import { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; import { useE2EERoomState } from '../hooks/useE2EERoomState'; import { useE2EEState } from '../hooks/useE2EEState'; @@ -12,7 +11,7 @@ const RoomHeaderE2EESetup = ({ room }: RoomHeaderProps) => { const e2eeState = useE2EEState(); const e2eRoomState = useE2EERoomState(room._id); - if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === E2ERoomState.WAITING_KEYS) { + if (e2eeState === 'SAVE_PASSWORD' || e2eeState === 'ENTER_PASSWORD' || e2eRoomState === 'WAITING_KEYS') { return } />; } diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx index cecb77c48a9a1..bcf9fb8d09cdb 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.spec.tsx @@ -2,7 +2,6 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { render, screen } from '@testing-library/react'; import MessageBoxHint from './MessageBoxHint'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useRoom } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -28,7 +27,7 @@ const renderOptions = { describe('MessageBoxHint', () => { beforeEach(() => { (useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: false }); - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.WAITING_KEYS); + (useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS'); }); describe('Editing message', () => { @@ -81,14 +80,14 @@ describe('MessageBoxHint', () => { }); it('does not renders hint text when E2ERoomState is READY', () => { - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.READY); + (useE2EERoomState as jest.Mock).mockReturnValue('READY'); render(, renderOptions); expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); }); it('does not renders hint text when E2ERoomState is DISABLED', () => { - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.DISABLED); + (useE2EERoomState as jest.Mock).mockReturnValue('DISABLED'); render(, renderOptions); expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument(); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx index b357f75553888..b08dc9c337f6d 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxHint.tsx @@ -3,7 +3,6 @@ import type { ReactElement } from 'react'; import { memo } from 'react'; import { useTranslation, Trans } from 'react-i18next'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useRoom } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -25,8 +24,8 @@ const MessageBoxHint = ({ isEditing, e2eEnabled, unencryptedMessagesAllowed, isM e2eEnabled && unencryptedMessagesAllowed && e2eRoomState && - e2eRoomState !== E2ERoomState.READY && - e2eRoomState !== E2ERoomState.DISABLED && + e2eRoomState !== 'READY' && + e2eRoomState !== 'DISABLED' && !isEditing && !isReadOnly; diff --git a/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts b/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts index c247c78de7a1b..66c178660db33 100644 --- a/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts +++ b/apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.spec.ts @@ -6,7 +6,7 @@ import { renderHook } from '@testing-library/react'; import { useChatMessagesInstance } from './useChatMessagesInstance'; import { ChatMessages } from '../../../../../app/ui/client/lib/ChatMessages'; import { useEmojiPicker } from '../../../../contexts/EmojiPickerContext'; -import { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; +import type { E2ERoomState } from '../../../../lib/e2ee/E2ERoomState'; import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionManager'; import { useRoomSubscription } from '../../contexts/RoomContext'; import { useE2EERoomState } from '../../hooks/useE2EERoomState'; @@ -67,7 +67,7 @@ describe('useChatMessagesInstance', () => { rid: 'roomId', }; mockActionManager = undefined; - mockE2EERoomState = E2ERoomState.READY; + mockE2EERoomState = 'READY'; mockEmojiPicker = { open: jest.fn(), isOpen: false, @@ -162,7 +162,7 @@ describe('useChatMessagesInstance', () => { expect(ChatMessages).toHaveBeenCalledTimes(1); expect(updateSubscriptionMock).toHaveBeenCalledTimes(1); - (useE2EERoomState as jest.Mock).mockReturnValue(E2ERoomState.WAITING_KEYS); + (useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS'); rerender(); diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md new file mode 100644 index 0000000000000..2478f4a09f598 --- /dev/null +++ b/packages/e2ee/docs/NOTES.md @@ -0,0 +1,30 @@ +# Notes + +## Bookmarks +- [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) +- [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) + +## Tasks + +### Shared Package +- Separate all client-side e2ee logic from core into zero-dependency runtime-agnostic package. + - Things like nodejs Buffer and SubtleCrypto are not available from React Native. +- Create adapter for web +- Create adapter for node (testing purposes) +- Create adapter for react-native + +### Refactor Web +- Remove use of enums for state in favor of literal types. + +### Dependency Cleanup +- Remove use of `ejson` for serializing/deserializing Uint8Array: [codec.ts](../src/codec.ts#L165-L175) + - It is simply a JSON with a "$binary" field containing the base64 encoded data. +- Remove use of `bytebuffer` for encoding/decoding strings: [binary.ts](../src/binary.ts) + - ByteBuffer.wrap('hello', 'binary').toArrayBuffer() + - ByteBuffer.wrap('hello').toString('binary') + +### Improve Tests +- Tests in [e2e-encryption.ts](../../../apps/meteor/tests/e2e/e2e-encryption.spec.ts) are failing when ran: + - More than once: lack of cleanup + - Out of order: inter-test dependencies + - Locally: slowness of CI and retries are masking bugs \ No newline at end of file From 255a31e898a05923cf2bd589db4a6d2b7167365a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 13:33:35 -0300 Subject: [PATCH 052/251] add more notes --- packages/e2ee/docs/NOTES.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 2478f4a09f598..84e1c649543ab 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -9,8 +9,8 @@ ### Shared Package - Separate all client-side e2ee logic from core into zero-dependency runtime-agnostic package. - Things like nodejs Buffer and SubtleCrypto are not available from React Native. -- Create adapter for web -- Create adapter for node (testing purposes) +- Create adapter for [web](../../e2ee-web/) +- Create adapter for [node](../../e2ee-node/) (testing purposes) - Create adapter for react-native ### Refactor Web @@ -27,4 +27,4 @@ - Tests in [e2e-encryption.ts](../../../apps/meteor/tests/e2e/e2e-encryption.spec.ts) are failing when ran: - More than once: lack of cleanup - Out of order: inter-test dependencies - - Locally: slowness of CI and retries are masking bugs \ No newline at end of file + - Locally: slowness of CI + retries are masking bugs (eg: in full page loads) \ No newline at end of file From aef27544b32ae8537b8fd4b3f5a643ac98e1b692 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 13:43:11 -0300 Subject: [PATCH 053/251] add core package link --- packages/e2ee/docs/NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 84e1c649543ab..49777599f0f7f 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -7,7 +7,7 @@ ## Tasks ### Shared Package -- Separate all client-side e2ee logic from core into zero-dependency runtime-agnostic package. +- Separate all client-side e2ee logic from core into zero-dependency runtime-agnostic [package](../src/index.ts). - Things like nodejs Buffer and SubtleCrypto are not available from React Native. - Create adapter for [web](../../e2ee-web/) - Create adapter for [node](../../e2ee-node/) (testing purposes) From 0078ef81e2e92a110c9dda180f2f984018c11fe2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 13:47:56 -0300 Subject: [PATCH 054/251] remove dependency on e2eeroomstate --- apps/meteor/client/views/room/hooks/useE2EERoomState.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/client/views/room/hooks/useE2EERoomState.ts b/apps/meteor/client/views/room/hooks/useE2EERoomState.ts index 3ccf8b712bda6..dbecb983b026a 100644 --- a/apps/meteor/client/views/room/hooks/useE2EERoomState.ts +++ b/apps/meteor/client/views/room/hooks/useE2EERoomState.ts @@ -1,7 +1,6 @@ import { useMemo, useSyncExternalStore } from 'react'; import { useE2EERoom } from './useE2EERoom'; -import type { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; export const useE2EERoomState = (rid: string) => { const e2eRoom = useE2EERoom(rid); @@ -10,7 +9,7 @@ export const useE2EERoomState = (rid: string) => { () => [ (callback: () => void): (() => void) => (e2eRoom ? e2eRoom.onStateChange(callback) : () => undefined), - (): E2ERoomState | undefined => (e2eRoom ? e2eRoom.getState() : undefined), + () => (e2eRoom ? e2eRoom.getState() : undefined), ] as const, [e2eRoom], ); From 916471377c4d45d0dc83678d2243a6308ac2a2b4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 13:57:58 -0300 Subject: [PATCH 055/251] add initial PRs --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 49777599f0f7f..68dc565db1388 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -3,6 +3,7 @@ ## Bookmarks - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) +- [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) ## Tasks From e83296e0745a85b3fb7cae1ea460b232b6490765 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 25 Aug 2025 14:00:04 -0300 Subject: [PATCH 056/251] add initial PRs --- packages/e2ee/docs/NOTES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 68dc565db1388..935b01d15fa1f 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -1,6 +1,7 @@ # Notes -## Bookmarks +## History +- [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) From 02eb05d67122b051ada436b5374691aa06be1475 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 10:44:48 -0300 Subject: [PATCH 057/251] add notes on AES-GCM --- packages/e2ee/docs/NOTES.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 935b01d15fa1f..6ec993e9687b2 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -29,4 +29,33 @@ - Tests in [e2e-encryption.ts](../../../apps/meteor/tests/e2e/e2e-encryption.spec.ts) are failing when ran: - More than once: lack of cleanup - Out of order: inter-test dependencies - - Locally: slowness of CI + retries are masking bugs (eg: in full page loads) \ No newline at end of file + - Locally: slowness of CI + retries are masking bugs (eg: in full page loads) + +### Introduce AES-GCM +- Add AES‑GCM primitives alongside AES‑CBC +- Introduce a versioned “envelope” that records the cipher mode +- Default new writes to AES‑GCM; keep multi‑strategy decryption with fallback to AES‑CBC +- Update key derivation to produce AES keys for either mode +- Add tests for both modes and for mixed-mode migration + +#### The compatibility strategy (WIP) +1. Versioned envelope + - V1: `{ version: '1', mode: 'AES-CBC', ivB64, ctB64, kdf: {…} }` + - V2: `{ version: '2', mode: 'AES-GCM', ivB64, ctB64, tagLen, aadB64?, kdf: { ... } }` +2. Dual-read, single-write + - On decode, try: + - If parseable as JSON and has version/mode: + - If `mode === AES‑GCM`: decrypt with AES‑GCM + - If `mode === AES‑CBC`: decrypt with AES‑CBC (existing code) + - Else (legacy “vector + ciphertext” blob): + - Treat as AES‑CBC with current [split/join helpers](../src/vector.ts). + - On encode, default to `AES‑GCM` for new data. + - All readers will still accept older `CBC` payloads. + - New payloads advertise `GCM`. +3. Derivation and key types + - WebCrypto requires the CryptoKey algorithm.name to match the operation (CBC vs GCM). + - Add a generic derivation that yields raw bits, then import for the required algorithm: + - deriveBits with PBKDF2: + - `importKey('raw', …, { name: 'AES-GCM'|'AES-CBC', ... }, false, ['encrypt','decrypt'])` + - Keep existing CBC derivation for old data. + - For new GCM data, import the same bits as AES‑GCM. \ No newline at end of file From 813ebf9a20b98f0a2ff54dfbea0ce5de6351ed15 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 10:57:44 -0300 Subject: [PATCH 058/251] add notes about Maintenance/Interop --- packages/e2ee/docs/NOTES.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 6ec993e9687b2..1fdb150e55bca 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -16,16 +16,23 @@ - Create adapter for react-native ### Refactor Web -- Remove use of enums for state in favor of literal types. -### Dependency Cleanup +#### Maintainance/Interop +- Remove use of non-javascript constructs: + - `enum` -> `literal types`. + - `private member` -> `#member`. + - Use `localStorage` and/or `sessionStorage` explicitly instead of: + [Accounts.storageLocation](../../../apps/meteor/definition/externals/meteor/accounts-base.d.ts). + [Meteor._localStorage](../../../apps/meteor/app/ui-master/server/scripts.ts) + +#### Dependency Cleanup - Remove use of `ejson` for serializing/deserializing Uint8Array: [codec.ts](../src/codec.ts#L165-L175) - It is simply a JSON with a "$binary" field containing the base64 encoded data. - Remove use of `bytebuffer` for encoding/decoding strings: [binary.ts](../src/binary.ts) - ByteBuffer.wrap('hello', 'binary').toArrayBuffer() - ByteBuffer.wrap('hello').toString('binary') -### Improve Tests +#### Improve Tests - Tests in [e2e-encryption.ts](../../../apps/meteor/tests/e2e/e2e-encryption.spec.ts) are failing when ran: - More than once: lack of cleanup - Out of order: inter-test dependencies From 862fde8d35d106acf8ad14fed24eb11566d6bdca Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 13:23:57 -0300 Subject: [PATCH 059/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 1fdb150e55bca..4e81aa639e863 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -5,6 +5,7 @@ - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) +- [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) ## Tasks From d6e0259af00ca9944a921f87ecb52b82f6d9fb8c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 14:29:00 -0300 Subject: [PATCH 060/251] add playground --- packages/e2ee/playground/messages.mongodb.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/e2ee/playground/messages.mongodb.js diff --git a/packages/e2ee/playground/messages.mongodb.js b/packages/e2ee/playground/messages.mongodb.js new file mode 100644 index 0000000000000..0b1caee70aaab --- /dev/null +++ b/packages/e2ee/playground/messages.mongodb.js @@ -0,0 +1,7 @@ +// oxlint-disable no-undef +use('meteor'); +db.getCollection('rocketchat_message').find({ + 'content.algorithm': { $exists: true }, + 'content.ciphertext': { $exists: true }, + 't': { $eq: 'e2e' }, +}); From 65dc4b8d30acfa47539ddf28e6cd0edc352fd178 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 16:56:07 -0300 Subject: [PATCH 061/251] implement AES GCM --- apps/meteor/client/lib/e2ee/helper.ts | 72 +++++++++++- .../client/lib/e2ee/rocketchat.e2e.room.ts | 105 ++++++++++++++---- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 49 +++++++- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 6 +- package.json | 1 + 5 files changed, 196 insertions(+), 37 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 3d51d9bc68bd9..c334cab44fa05 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -1,5 +1,42 @@ import ByteBuffer from 'bytebuffer'; +export const isJsonWebKey = (data: unknown): data is JsonWebKey => { + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + if (typeof obj.kty !== 'string') { + return false; + } + + switch (obj.kty) { + case 'oct': { + // Symmetric key: must have "k" + return typeof obj.k === 'string'; + } + case 'RSA': { + // RSA public/private key: must have "n" and "e" + return typeof obj.n === 'string' && typeof obj.e === 'string'; + } + case 'EC': { + // EC public/private key: must have "crv", "x", "y" + return typeof obj.crv === 'string' && typeof obj.x === 'string' && typeof obj.y === 'string'; + } + case 'OKP': { + // OKP (e.g., Ed25519): must have "crv" and "x" + return typeof obj.crv === 'string' && typeof obj.x === 'string'; + } + default: + return false; + } +}; + +export const isAesGcm = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A256GCM' } => + jwk.kty === 'oct' && jwk.alg === 'A256GCM'; +export const isAesCbc = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A128CBC' } => + jwk.kty === 'oct' && jwk.alg === 'A128CBC'; + export function toString(thing: any) { if (typeof thing === 'string') { return thing; @@ -44,10 +81,18 @@ export async function encryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); } -export async function encryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function encryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } +export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, data: BufferSource) { + return crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); +} + +export async function decryptAesGcm(iv: Uint8Array, key: CryptoKey, data: Uint8Array) { + return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); +} + export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } @@ -56,14 +101,22 @@ export async function decryptRSA(key: CryptoKey, data: Uint8Array) return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } -export async function decryptAES(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { +export async function decryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); } -export async function generateAESKey() { +/** + * Generates a new AES-CBC key. + * @deprecated Use {@link generateAesGcmKey} instead. + */ +export async function generateAesCbcKey() { return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); } +export async function generateAesGcmKey() { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +} + export async function generateAESCTRKey() { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } @@ -100,10 +153,21 @@ export async function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray ); } -export async function importAESKey(keyData: any, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { +/** + * Imports an AES-CBC key from JWK format. + * @deprecated Use {@link importAesGcmKey} instead. + */ +export async function importAesCbcKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages); } +/** + * Imports an AES-GCM key from JWK format. + */ +export async function importAesGcmKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { + return crypto.subtle.importKey('jwk', keyData, { name: 'AES-GCM' }, true, keyUsages); +} + export async function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArray = ['deriveKey']) { return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 06a774a19c2f9..06ffee5556b36 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -11,20 +11,26 @@ import { joinVectorAndEcryptedData, splitVectorAndEcryptedData, encryptRSA, - encryptAES, + encryptAesCbc, + encryptAesGcm, + decryptAesGcm, decryptRSA, - decryptAES, - generateAESKey, + decryptAesCbc, exportJWKKey, - importAESKey, + importAesCbcKey, importRSAKey, readFileAsArrayBuffer, encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, + generateAesGcmKey, + // generateAesCbcKey, + importAesGcmKey, + isJsonWebKey, + isAesCbc, + isAesGcm, } from './helper'; -import { log, logError } from './logger'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; @@ -114,11 +120,11 @@ export class E2ERoom extends Emitter { } log(...msg: unknown[]) { - log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + e2e.log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); } error(...msg: unknown[]) { - logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + e2e.error(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); } hasSessionKey() { @@ -218,21 +224,21 @@ export class E2ERoom extends Emitter { const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { - this.log('decryptSubscriptions nothing to do'); + e2e.log('decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - this.log('decryptSubscriptions updating lastMessage'); + e2e.log('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - this.log('decryptSubscriptions Done'); + e2e.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { @@ -348,7 +354,17 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - return importAESKey(JSON.parse(await this.exportSessionKey(key))); + const jwk = JSON.parse(await this.exportSessionKey(key)); + try { + return await importAesGcmKey(jwk); + } catch (error) { + this.error(error); + try { + return await importAesCbcKey(jwk); + } catch (error) { + throw new Error('Failed to import session key', { cause: error }); + } + } } async exportSessionKey(key: string) { @@ -388,21 +404,44 @@ export class E2ERoom extends Emitter { this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } - // Import session key for use. - try { - const key = await importAESKey(JSON.parse(this.sessionKeyExportedString!)); - // Key has been obtained. E2E is now in session. - this.groupSessionKey = key; - } catch (error) { - this.error('Error importing group key: ', error); - return false; + const jwk = (() => { + try { + const data: unknown = JSON.parse(this.sessionKeyExportedString); + if (isJsonWebKey(data)) { + return data; + } + throw new TypeError(`Invalid JWK: ${JSON.stringify(data)}`); + } catch (error) { + this.error('Error parsing JWK: ', error); + throw new Error('Failed to parse JWK'); + } + })(); + + if (isAesCbc(jwk)) { + try { + const key = await importAesCbcKey(jwk); + this.groupSessionKey = key; + return true; + } catch (error) { + this.error('Error importing AES-CBC key: ', error); + return false; + } } - return true; + if (isAesGcm(jwk)) { + try { + const key = await importAesGcmKey(jwk); + this.groupSessionKey = key; + return true; + } catch (error) { + this.error('Error importing AES-GCM key: ', error); + return false; + } + } } async createNewGroupKey() { - this.groupSessionKey = await generateAESKey(); + this.groupSessionKey = await generateAesGcmKey(); const sessionKeyExported = await exportJWKKey(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); @@ -603,8 +642,22 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await encryptAES(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); + + const algo = this.groupSessionKey.algorithm; + + switch (algo.name) { + case 'AES-GCM': { + const result = await encryptAesGcm(vector, this.groupSessionKey, data); + return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); + } + case 'AES-CBC': { + const result = await encryptAesCbc(vector, this.groupSessionKey, data); + return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); + } + default: { + throw new Error(`Unsupported encryption algorithm: ${algo}`); + } + } } catch (error) { this.error('Error encrypting message: ', error); throw error; @@ -695,7 +748,11 @@ export class E2ERoom extends Emitter { } async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { - const result = await decryptAES(vector, key, cipherText); + if (key.algorithm.name === 'AES-GCM') { + const result = await decryptAesGcm(vector, key, cipherText); + return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); + } + const result = await decryptAesCbc(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 9d3a57332ecf2..94c8795848978 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -12,7 +12,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -import { log, logError } from './logger'; +// import { log, logError, logWarning } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; @@ -34,7 +34,39 @@ let failedToDecodeKey = false; const ROOM_KEY_EXCHANGE_SIZE = 10; -// +class Logger { + constructor(private level: 0 | 1 | 2 | 3 | 4) {} + + log(...data: unknown[]) { + if (this.level <= 0) { + console.log('E2E', ...data); + } + } + + info(...data: unknown[]) { + if (this.level <= 1) { + console.info('E2E', ...data); + } + } + + warn(...data: unknown[]) { + if (this.level <= 2) { + console.warn('E2E', ...data); + } + } + + error(...data: unknown[]) { + if (this.level <= 3) { + console.error('E2E', ...data); + } + } + + debug(...data: unknown[]) { + if (this.level <= 4) { + console.debug('E2E', ...data); + } + } +} class E2E extends Emitter<{ READY: void; @@ -64,11 +96,14 @@ class E2E extends Emitter<{ private e2ee: E2EE; + private logger: Logger; + constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; + this.logger = new Logger(3); this.e2ee = E2EE.withLocalStorage( { userId: () => Promise.resolve(Meteor.userId()), @@ -110,11 +145,15 @@ class E2E extends Emitter<{ } log(...msg: unknown[]) { - log('E2E', ...msg); + this.logger.log('E2E', ...msg); } error(...msg: unknown[]) { - logError('E2E', ...msg); + this.logger.error('E2E', ...msg); + } + + warn(...msg: unknown[]) { + this.logger.warn('E2E', ...msg); } getState() { @@ -160,7 +199,7 @@ class E2E extends Emitter<{ await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + this.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); await this.rejectSuggestedKey(sub.rid); } } diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 7dedd05c6cf4d..de8522b781463 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -446,8 +446,6 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.dismissToast(); - // TODO: Fix the kebab menu disappearing flakiness - await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await poHomeChannel.tabs.kebab.click({ force: true }); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); @@ -943,7 +941,7 @@ test.describe.fixme('e2ee room setup', () => { await poAccountProfile.securityE2EEncryptionSection.click(); await poAccountProfile.securityE2EEncryptionResetKeyButton.click(); - await page.waitForURL('/home'); + await page.locator('role=button[name="Login"]').waitFor(); await injectInitialData(); await restoreState(page, Users.admin); @@ -1162,7 +1160,7 @@ test.describe('e2ee support legacy formats', () => { }, }); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message', { timeout: 10000 }); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); }); diff --git a/package.json b/package.json index f54976225cf3b..22b848882defe 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test-storybook": "turbo run test-storybook", "dev": "turbo run dev --env-mode=loose --parallel --filter=@rocket.chat/meteor...", "dsv": "turbo run dsv --env-mode=loose --filter=@rocket.chat/meteor...", + "tsv": "TEST_MODE=true yarn dsv", "lint": "turbo run lint", "storybook": "yarn workspace @rocket.chat/meteor run storybook", "fuselage": "./fuselage.sh", From cde3b45b6185e1d0863b7cc438f35e73cb9a42aa Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 26 Aug 2025 19:06:53 -0300 Subject: [PATCH 062/251] jwk helpers --- apps/meteor/client/lib/e2ee/helper.ts | 37 ------------- apps/meteor/client/lib/e2ee/jwk.ts | 44 +++++++++++++++ .../client/lib/e2ee/rocketchat.e2e.room.ts | 35 +++++------- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 53 ++++++++++--------- .../page-objects/fragments/home-sidenav.ts | 19 +++++-- 5 files changed, 99 insertions(+), 89 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/jwk.ts diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index c334cab44fa05..a6eb057765dad 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -1,42 +1,5 @@ import ByteBuffer from 'bytebuffer'; -export const isJsonWebKey = (data: unknown): data is JsonWebKey => { - if (typeof data !== 'object' || data === null) { - return false; - } - - const obj = data as Record; - if (typeof obj.kty !== 'string') { - return false; - } - - switch (obj.kty) { - case 'oct': { - // Symmetric key: must have "k" - return typeof obj.k === 'string'; - } - case 'RSA': { - // RSA public/private key: must have "n" and "e" - return typeof obj.n === 'string' && typeof obj.e === 'string'; - } - case 'EC': { - // EC public/private key: must have "crv", "x", "y" - return typeof obj.crv === 'string' && typeof obj.x === 'string' && typeof obj.y === 'string'; - } - case 'OKP': { - // OKP (e.g., Ed25519): must have "crv" and "x" - return typeof obj.crv === 'string' && typeof obj.x === 'string'; - } - default: - return false; - } -}; - -export const isAesGcm = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A256GCM' } => - jwk.kty === 'oct' && jwk.alg === 'A256GCM'; -export const isAesCbc = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A128CBC' } => - jwk.kty === 'oct' && jwk.alg === 'A128CBC'; - export function toString(thing: any) { if (typeof thing === 'string') { return thing; diff --git a/apps/meteor/client/lib/e2ee/jwk.ts b/apps/meteor/client/lib/e2ee/jwk.ts new file mode 100644 index 0000000000000..dc23edfb252b3 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/jwk.ts @@ -0,0 +1,44 @@ +export const isJsonWebKey = (data: unknown): data is JsonWebKey => { + if (typeof data !== 'object' || data === null) { + return false; + } + + const obj = data as Record; + if (typeof obj.kty !== 'string') { + return false; + } + + switch (obj.kty) { + case 'oct': { + // Symmetric key: must have "k" + return typeof obj.k === 'string'; + } + case 'RSA': { + // RSA public/private key: must have "n" and "e" + return typeof obj.n === 'string' && typeof obj.e === 'string'; + } + case 'EC': { + // EC public/private key: must have "crv", "x", "y" + return typeof obj.crv === 'string' && typeof obj.x === 'string' && typeof obj.y === 'string'; + } + case 'OKP': { + // OKP (e.g., Ed25519): must have "crv" and "x" + return typeof obj.crv === 'string' && typeof obj.x === 'string'; + } + default: + return false; + } +}; + +export const isAesGcm = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A256GCM' } => + jwk.kty === 'oct' && jwk.alg === 'A256GCM'; +export const isAesCbc = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A128CBC' } => + jwk.kty === 'oct' && jwk.alg === 'A128CBC'; + +export const parseJsonWebKey = (json: string): JsonWebKey => { + const obj = JSON.parse(json); + if (!isJsonWebKey(obj)) { + throw new Error('Invalid JSON Web Key'); + } + return obj; +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 06ffee5556b36..df8eb2c88f15b 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -27,10 +27,8 @@ import { generateAesGcmKey, // generateAesCbcKey, importAesGcmKey, - isJsonWebKey, - isAesCbc, - isAesGcm, } from './helper'; +import { isAesCbc, isAesGcm, parseJsonWebKey } from './jwk'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; @@ -260,6 +258,7 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + e, ); keys.push({ ...key, E2EKey: null }); } @@ -291,6 +290,7 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + e, ); } } @@ -344,7 +344,7 @@ export class E2ERoom extends Emitter { this.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - // this.error = error; + this.error('Error requesting room key: ', error); this.setState('ERROR'); } } @@ -354,7 +354,7 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - const jwk = JSON.parse(await this.exportSessionKey(key)); + const jwk = parseJsonWebKey(await this.exportSessionKey(key)); try { return await importAesGcmKey(jwk); } catch (error) { @@ -404,37 +404,26 @@ export class E2ERoom extends Emitter { this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } - const jwk = (() => { - try { - const data: unknown = JSON.parse(this.sessionKeyExportedString); - if (isJsonWebKey(data)) { - return data; - } - throw new TypeError(`Invalid JWK: ${JSON.stringify(data)}`); - } catch (error) { - this.error('Error parsing JWK: ', error); - throw new Error('Failed to parse JWK'); - } - })(); + const jwk = parseJsonWebKey(this.sessionKeyExportedString); - if (isAesCbc(jwk)) { + if (isAesGcm(jwk)) { try { - const key = await importAesCbcKey(jwk); + const key = await importAesGcmKey(jwk); this.groupSessionKey = key; return true; } catch (error) { - this.error('Error importing AES-CBC key: ', error); + this.error('Error importing AES-GCM key: ', error); return false; } } - if (isAesGcm(jwk)) { + if (isAesCbc(jwk)) { try { - const key = await importAesGcmKey(jwk); + const key = await importAesCbcKey(jwk); this.groupSessionKey = key; return true; } catch (error) { - this.error('Error importing AES-GCM key: ', error); + this.error('Error importing AES-CBC key: ', error); return false; } } diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index de8522b781463..5971dedddfb5f 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -260,6 +260,8 @@ test.describe('basic features', () => { // Login again await loginPage.loginByUserState(Users.admin); + await expect(sidenav.btnCreateNew).toBeVisible(); + await sidenav.openChat(channelName); await expect(encryptedRoomPage.encryptedIcon).toBeVisible(); @@ -446,9 +448,9 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.dismissToast(); - await poHomeChannel.tabs.kebab.click({ force: true }); + await poHomeChannel.tabs.kebab.click(); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); - await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await poHomeChannel.tabs.btnEnableE2E.click(); await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); await page.getByRole('button', { name: 'Enable encryption' }).click(); await page.waitForTimeout(1000); @@ -591,16 +593,10 @@ test.describe.serial('e2e-encryption', () => { await test.step('create an encrypted channel', async () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); @@ -620,16 +616,10 @@ test.describe.serial('e2e-encryption', () => { await test.step('create an encrypted room', async () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); @@ -690,16 +680,10 @@ test.describe.serial('e2e-encryption', () => { await test.step('create an encrypted channel', async () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); }); @@ -827,7 +811,7 @@ test.describe.serial('e2e-encryption', () => { await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); // Check last message in the sidebar - const sidebarChannel = await poHomeChannel.sidenav.getSidebarItemByName(channelName); + const sidebarChannel = poHomeChannel.sidenav.getSidebarItemByName(channelName); await expect(sidebarChannel).toBeVisible(); await expect(sidebarChannel.locator('span')).toContainText(encriptedMessage1); }); @@ -1110,6 +1094,25 @@ test.describe('e2ee support legacy formats', () => { test.beforeEach(async ({ page }) => { poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + + const loginPage = new LoginPage(page); + + // Wait for either the app shell or login form to render + await Promise.race([ + poHomeChannel.sidenav.btnCreateNew.waitFor({ state: 'visible' }), + loginPage.loginButton.waitFor({ state: 'visible' }), + ]).catch(() => { + /* allow follow-up check below */ + }); + + // If logged out, log back in and wait for the sidebar to be ready + if (await loginPage.loginButton.isVisible().catch(() => false)) { + await loginPage.loginByUserState(Users.userE2EE); + } + + // Ensure the sidebar is ready before interacting + await expect(poHomeChannel.sidenav.btnCreateNew).toBeVisible(); }); test.beforeAll(async ({ api }) => { @@ -1124,8 +1127,6 @@ test.describe('e2ee support legacy formats', () => { // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { - await page.goto('/home'); - const channelName = faker.string.uuid(); await poHomeChannel.sidenav.createEncryptedChannel(channelName); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 0318752e96885..f1ef89c923995 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -19,7 +19,7 @@ export class HomeSidenav { } get checkboxEncryption(): Locator { - return this.page.locator('role=dialog[name="Create channel"] >> label >> text="Encrypted"'); + return this.page.getByRole('dialog', { name: 'Create channel' }).getByRole('checkbox', { name: 'Encrypted' }); } get checkboxReadOnly(): Locator { @@ -222,11 +222,24 @@ export class HomeSidenav { async createEncryptedChannel(name: string) { const toastMessages = new ToastMessages(this.page); + // Open modal and ensure it's visible before interacting await this.openNewByLabel('Channel'); + const dialog = this.page.getByRole('dialog', { name: 'Create channel' }); + await expect(dialog).toBeVisible(); + await this.inputChannelName.fill(name); + + // Expand and wait for checkbox to appear before clicking await this.advancedSettingsAccordion.click(); - await this.checkboxEncryption.click(); - await this.btnCreate.click(); + await expect(this.checkboxEncryption).toBeVisible(); + + await this.page.getByRole('button', { name: 'Advanced settings' }).click(); + await this.page + .locator('span') + .filter({ hasText: /^Encrypted$/ }) + .locator('i') + .click(); + await this.page.getByRole('button', { name: 'Create', exact: true }).click(); await toastMessages.dismissToast('success'); } From 250d1c166dc84525a49720823f0780d9947e1e22 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 27 Aug 2025 11:36:07 -0300 Subject: [PATCH 063/251] sync --- apps/meteor/client/views/root/AppLayout.tsx | 4 ++-- packages/e2ee/playground/messages.mongodb.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index cb671e3fea60a..327dc95d46bab 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -28,7 +28,7 @@ import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking import { useAutoupdate } from '../../hooks/useAutoupdate'; import { useLoadRoomForAllowedAnonymousRead } from '../../hooks/useLoadRoomForAllowedAnonymousRead'; import { appLayout } from '../../lib/appLayout'; -import { useRedirectToSetupWizard } from '../../startup/useRedirectToSetupWizard'; +// import { useRedirectToSetupWizard } from '../../startup/useRedirectToSetupWizard'; const AppLayout = () => { useEffect(() => { @@ -48,7 +48,7 @@ const AppLayout = () => { useLoadRoomForAllowedAnonymousRead(); useNotificationPermission(); useEmojiOne(); - useRedirectToSetupWizard(); + // useRedirectToSetupWizard(); useSettingsOnLoadSiteUrl(); useLivechatEnterprise(); useNextcloudOAuth(); diff --git a/packages/e2ee/playground/messages.mongodb.js b/packages/e2ee/playground/messages.mongodb.js index 0b1caee70aaab..f7b031779461e 100644 --- a/packages/e2ee/playground/messages.mongodb.js +++ b/packages/e2ee/playground/messages.mongodb.js @@ -1,7 +1,7 @@ // oxlint-disable no-undef use('meteor'); db.getCollection('rocketchat_message').find({ - 'content.algorithm': { $exists: true }, - 'content.ciphertext': { $exists: true }, - 't': { $eq: 'e2e' }, + // 'content.algorithm': { $exists: true }, + // 'content.ciphertext': { $exists: true }, + t: { $eq: 'e2e' }, }); From 7e0bf1d64cf919f69968f100d01838ed20fc809b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 27 Aug 2025 13:49:32 -0300 Subject: [PATCH 064/251] update playwright --- apps/meteor/package.json | 4 ++-- packages/e2ee-web/package.json | 2 +- packages/ui-voip/package.json | 2 +- yarn.lock | 36 +++++++++++++++++----------------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 07d9d374af364..e519f4a4fb943 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -72,7 +72,7 @@ "@babel/preset-react": "~7.25.9", "@babel/register": "~7.25.9", "@faker-js/faker": "~8.0.2", - "@playwright/test": "~1.54.2", + "@playwright/test": "~1.55.0", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/livechat": "workspace:^", @@ -193,7 +193,7 @@ "nyc": "^17.1.0", "outdent": "~0.8.0", "pino-pretty": "^7.6.1", - "playwright-core": "~1.54.2", + "playwright-core": "~1.55.0", "playwright-qase-reporter": "^2.1.5", "postcss": "~8.4.49", "postcss-custom-properties": "^14.0.6", diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json index 40bf39b9e61ec..ec67960df3aee 100644 --- a/packages/e2ee-web/package.json +++ b/packages/e2ee-web/package.json @@ -29,7 +29,7 @@ "@vitest/browser": "4.0.0-beta.8", "oxlint": "~1.12.0", "oxlint-tsgolint": "~0.0.4", - "playwright": "~1.54.2", + "playwright": "~1.55.0", "typescript": "~5.9.2", "vitest": "4.0.0-beta.8" }, diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index d29d095061ad6..b99006addd905 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@babel/core": "~7.26.10", "@faker-js/faker": "~8.0.2", - "@playwright/test": "~1.54.2", + "@playwright/test": "~1.55.0", "@react-spectrum/test-utils": "~1.0.0-alpha.8", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 2d1336f467748..0d0b5d4164150 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5092,14 +5092,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:~1.54.2": - version: 1.54.2 - resolution: "@playwright/test@npm:1.54.2" +"@playwright/test@npm:~1.55.0": + version: 1.55.0 + resolution: "@playwright/test@npm:1.55.0" dependencies: - playwright: "npm:1.54.2" + playwright: "npm:1.55.0" bin: playwright: cli.js - checksum: 10/33d6c0780ef6cca12c008cae166ba06978ae975138a1654e14cd8691ad7826dbbc03e492ed42c65889c44ca893d0fd47d8ae4dc8d824020d7f767260b395dc6b + checksum: 10/bfaede669b0583ee6ec48bfe9ff531cac58da1b83031907d5d0fd71c33bab6766669a23359e401066d97c8e8fa76566900f71be7b290d12213ebb3530a82afbb languageName: node linkType: hard @@ -7432,7 +7432,7 @@ __metadata: "@vitest/browser": "npm:4.0.0-beta.8" oxlint: "npm:~1.12.0" oxlint-tsgolint: "npm:~0.0.4" - playwright: "npm:~1.54.2" + playwright: "npm:~1.55.0" typescript: "npm:~5.9.2" vitest: "npm:4.0.0-beta.8" languageName: unknown @@ -8073,7 +8073,7 @@ __metadata: "@opentelemetry/exporter-trace-otlp-grpc": "npm:^0.54.2" "@opentelemetry/sdk-node": "npm:^0.54.2" "@parse/node-apn": "npm:^6.3.0" - "@playwright/test": "npm:~1.54.2" + "@playwright/test": "npm:~1.55.0" "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@react-pdf/renderer": "npm:^3.4.5" "@rocket.chat/account-utils": "workspace:^" @@ -8371,7 +8371,7 @@ __metadata: path-to-regexp: "npm:^6.3.0" pino: "npm:^8.21.0" pino-pretty: "npm:^7.6.1" - playwright-core: "npm:~1.54.2" + playwright-core: "npm:~1.55.0" playwright-qase-reporter: "npm:^2.1.5" postcss: "npm:~8.4.49" postcss-custom-properties: "npm:^14.0.6" @@ -9425,7 +9425,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.26.10" "@faker-js/faker": "npm:~8.0.2" - "@playwright/test": "npm:~1.54.2" + "@playwright/test": "npm:~1.55.0" "@react-spectrum/test-utils": "npm:~1.0.0-alpha.8" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" @@ -29605,12 +29605,12 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.54.2, playwright-core@npm:~1.54.2": - version: 1.54.2 - resolution: "playwright-core@npm:1.54.2" +"playwright-core@npm:1.55.0, playwright-core@npm:~1.55.0": + version: 1.55.0 + resolution: "playwright-core@npm:1.55.0" bin: playwright-core: cli.js - checksum: 10/8eb8b37d7b02c2394f85567c5703464ee7c85929e1de7118946548a0277cacd41a6f247b8f7eb50c921433346f785356eff8132a9d3c6c2dcf3402c6a83e4f18 + checksum: 10/843376a8e2c1100d3f0625b2c85a69dc4418a0b268bdc7da6cf28252f6dc50c299c734f090a67c7f36cfa9140dacf5b95e817664e69642bcbaf06eea4d216c35 languageName: node linkType: hard @@ -29627,18 +29627,18 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.54.2, playwright@npm:~1.54.2": - version: 1.54.2 - resolution: "playwright@npm:1.54.2" +"playwright@npm:1.55.0, playwright@npm:~1.55.0": + version: 1.55.0 + resolution: "playwright@npm:1.55.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.54.2" + playwright-core: "npm:1.55.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/3f4fedc1e2290b6da6960eedf0d78b9097251848b499b0627186ca85e94178fe6be1590c4926246ff4dad5729fc80ab63fee6554fe736d29c0a1808ec483467f + checksum: 10/df02529693923b9b671822dac8a2ac8a8471c9142a05e14a24f8d0038a7dcf705712363991a070fc927466bbbe70e485145cf7a77882613822f56960afc4b892 languageName: node linkType: hard From 976a963910c65b5d0713a75a30c2b1858f0d6c40 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 28 Aug 2025 14:51:19 -0300 Subject: [PATCH 065/251] logger --- .github/workflows/playwright.yml | 27 +++ .gitignore | 6 + apps/meteor/client/lib/e2ee/logger.ts | 46 +++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 102 +++++------ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 89 +++------- .../root/hooks/loggedIn/useE2EEncryption.ts | 15 +- package.json | 166 +++++++++--------- playwright.config.ts | 90 ++++++++++ playwright/fixtures.ts | 70 ++++++++ tests/example.spec.ts | 14 ++ yarn.lock | 20 ++- 11 files changed, 422 insertions(+), 223 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 playwright/fixtures.ts create mode 100644 tests/example.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000000..a94b6417ac4a6 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm install -g yarn && yarn + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + - name: Run Playwright tests + run: yarn playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 8741b33f36c35..2d0c98768cc0d 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,9 @@ registration.yaml storybook-static development/tempo-data/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 5ef4e4b02bbc7..3a7b74fc209cf 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,19 +1,39 @@ -import { getConfig } from '../utils/getConfig'; +class Logger { + level: 0 | 1 | 2 | 3 | 4; -let debug: boolean | undefined = undefined; + constructor(level: 0 | 1 | 2 | 3 | 4) { + this.level = level; + } + + log(...data: unknown[]) { + if (this.level <= 0) { + console.log(...data); + } + } -const isDebugEnabled = (): boolean => { - if (debug === undefined) { - debug = getConfig('debug') === 'true' || getConfig('debug-e2e') === 'true'; + info(...data: unknown[]) { + if (this.level <= 1) { + console.info(...data); + } } - return debug; -}; + warn(...data: unknown[]) { + if (this.level <= 2) { + console.warn(...data); + } + } -export const log = (context: string, ...msg: unknown[]): void => { - isDebugEnabled() && console.log(`[${context}]`, ...msg); -}; + error(...data: unknown[]) { + if (this.level <= 3) { + console.error(...data); + } + } + + debug(...data: unknown[]) { + if (this.level <= 4) { + console.debug(...data); + } + } +} -export const logError = (context: string, ...msg: unknown[]): void => { - isDebugEnabled() && console.error(`[${context}]`, ...msg); -}; +export const logger = new Logger(4); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index df8eb2c88f15b..f0f5cf9aa743e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -29,6 +29,7 @@ import { importAesGcmKey, } from './helper'; import { isAesCbc, isAesGcm, parseJsonWebKey } from './jwk'; +import { logger } from './logger'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; @@ -109,7 +110,7 @@ export class E2ERoom extends Emitter { this.once('READY', () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === RoomManager.opened) { - this.log(`[PREV: ${prev}]`, 'State CHANGED'); + logger.log(`[PREV: ${prev}]`, 'State CHANGED'); } }); this.on('STATE_CHANGED', () => this.handshake()); @@ -117,14 +118,6 @@ export class E2ERoom extends Emitter { this.setState('NOT_STARTED'); } - log(...msg: unknown[]) { - e2e.log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); - } - - error(...msg: unknown[]) { - e2e.error(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); - } - hasSessionKey() { return !!this.groupSessionKey; } @@ -138,12 +131,12 @@ export class E2ERoom extends Emitter { const nextState = filterMutation(currentState, requestedState); if (!nextState) { - this.error(`invalid state ${currentState} -> ${requestedState}`); + logger.error(`invalid state ${currentState} -> ${requestedState}`); return; } this.state = nextState; - this.log(currentState, '->', nextState); + logger.log(currentState, '->', nextState); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -169,13 +162,13 @@ export class E2ERoom extends Emitter { } pause() { - this.log('PAUSED', this[PAUSED], '->', true); + logger.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - this.log('PAUSED', this[PAUSED], '->', false); + logger.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } @@ -222,28 +215,28 @@ export class E2ERoom extends Emitter { const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { - e2e.log('decryptSubscriptions nothing to do'); + logger.log('decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - e2e.log('decryptSubscriptions updating lastMessage'); + logger.log('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - e2e.log('decryptSubscriptions Done'); + logger.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - this.log('decryptOldRoomKeys nothing to do'); + logger.log('decryptOldRoomKeys nothing to do'); return; } @@ -256,7 +249,7 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - this.error( + logger.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, e, ); @@ -265,13 +258,13 @@ export class E2ERoom extends Emitter { } this.oldKeys = keys; - this.log('decryptOldRoomKeys Done'); + logger.log('decryptOldRoomKeys Done'); } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - this.log('exportOldRoomKeys starting'); + logger.log('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { - this.log('exportOldRoomKeys nothing to do'); + logger.log('exportOldRoomKeys nothing to do'); return; } @@ -288,14 +281,14 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - this.error( + logger.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, e, ); } } - this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + logger.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); return keys; } @@ -327,7 +320,7 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - this.error('Error fetching group key: ', error); + logger.error('Error fetching group key: ', error); return; } @@ -341,10 +334,10 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - this.log('Requesting room key'); + logger.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - this.error('Error requesting room key: ', error); + logger.error('Error requesting room key: ', error); this.setState('ERROR'); } } @@ -358,7 +351,7 @@ export class E2ERoom extends Emitter { try { return await importAesGcmKey(jwk); } catch (error) { - this.error(error); + logger.error(error); try { return await importAesCbcKey(jwk); } catch (error) { @@ -380,7 +373,7 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string) { - this.log('Importing room key ->', this.roomId); + logger.log('Importing room key ->', this.roomId); // Get existing group key // const keyID = groupKey.slice(0, 12); groupKey = groupKey.slice(12); @@ -394,7 +387,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - this.error('Error decrypting group key: ', error); + logger.error('Error decrypting group key: ', error); return false; } @@ -412,7 +405,7 @@ export class E2ERoom extends Emitter { this.groupSessionKey = key; return true; } catch (error) { - this.error('Error importing AES-GCM key: ', error); + logger.error('Error importing AES-GCM key: ', error); return false; } } @@ -423,7 +416,7 @@ export class E2ERoom extends Emitter { this.groupSessionKey = key; return true; } catch (error) { - this.error('Error importing AES-CBC key: ', error); + logger.error('Error importing AES-CBC key: ', error); return false; } } @@ -438,12 +431,17 @@ export class E2ERoom extends Emitter { } async createGroupKey() { - this.log('Creating room key'); + logger.log('Creating room key'); try { await this.createNewGroupKey(); await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); + + if (!e2e.publicKey) { + throw new Error('No public key found'); + } + + const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey); if (myKey) { await sdk.rest.post('/v1/e2e.updateGroupKey', { rid: this.roomId, @@ -453,15 +451,15 @@ export class E2ERoom extends Emitter { await this.encryptKeyForOtherParticipants(); } } catch (error) { - this.error('Error exporting group key: ', error); + logger.error('Error exporting group key: ', error); throw error; } } async resetRoomKey() { - this.log('Resetting room key'); + logger.log('Resetting room key'); if (!e2e.publicKey) { - this.error('Cannot reset room key. No public key found.'); + logger.error('Cannot reset room key. No public key found.'); return; } @@ -472,17 +470,17 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - this.log(`Room key reset done for room ${this.roomId}`); + logger.log(`Room key reset done for room ${this.roomId}`); return e2eNewKeys; } catch (error) { - this.error('Error resetting group key: ', error); + logger.error('Error resetting group key: ', error); throw error; } } onRoomKeyReset(keyID: string) { - this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + logger.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -511,12 +509,16 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(JSON.parse(user.e2e!.public_key!)); + const publicKey = user.e2e?.public_key; + if (!publicKey) { + return; + } + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(JSON.parse(publicKey)); if (!encryptedGroupKey) { return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key!, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(publicKey, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; @@ -527,7 +529,7 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return this.error('Error getting room users: ', error); + return logger.error('Error getting room users: ', error); } } @@ -541,7 +543,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return this.error('Error importing user key: ', error); + return logger.error('Error importing user key: ', error); } try { @@ -557,7 +559,7 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return this.error('Error encrypting user key: ', error); + return logger.error('Error encrypting user key: ', error); } } @@ -566,7 +568,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(publicKey, ['encrypt']); } catch (error) { - return this.error('Error importing user key: ', error); + return logger.error('Error importing user key: ', error); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -576,7 +578,7 @@ export class E2ERoom extends Emitter { const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; } catch (error) { - return this.error('Error encrypting user key: ', error); + return logger.error('Error encrypting user key: ', error); } } @@ -597,7 +599,7 @@ export class E2ERoom extends Emitter { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { console.log(error); - return this.error('Error encrypting group key: ', error); + return logger.error('Error encrypting group key: ', error); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); @@ -648,7 +650,7 @@ export class E2ERoom extends Emitter { } } } catch (error) { - this.error('Error encrypting message: ', error); + logger.error('Error encrypting message: ', error); throw error; } } @@ -766,7 +768,7 @@ export class E2ERoom extends Emitter { } return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - this.error('Error decrypting message: ', error, message); + logger.error('Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; } } @@ -782,7 +784,7 @@ export class E2ERoom extends Emitter { } return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - this.error('Error decrypting message: ', error, message); + logger.error('Error decrypting message: ', error, message); return { msg: t('E2E_Key_Error') }; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 94c8795848978..ac9be352f5c0f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -12,7 +12,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -// import { log, logError, logWarning } from './logger'; +import { logger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { settings } from '../../../app/settings/client'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; @@ -34,40 +34,6 @@ let failedToDecodeKey = false; const ROOM_KEY_EXCHANGE_SIZE = 10; -class Logger { - constructor(private level: 0 | 1 | 2 | 3 | 4) {} - - log(...data: unknown[]) { - if (this.level <= 0) { - console.log('E2E', ...data); - } - } - - info(...data: unknown[]) { - if (this.level <= 1) { - console.info('E2E', ...data); - } - } - - warn(...data: unknown[]) { - if (this.level <= 2) { - console.warn('E2E', ...data); - } - } - - error(...data: unknown[]) { - if (this.level <= 3) { - console.error('E2E', ...data); - } - } - - debug(...data: unknown[]) { - if (this.level <= 4) { - console.debug('E2E', ...data); - } - } -} - class E2E extends Emitter<{ READY: void; E2E_STATE_CHANGED: { prevState: E2EEState; nextState: E2EEState }; @@ -96,14 +62,11 @@ class E2E extends Emitter<{ private e2ee: E2EE; - private logger: Logger; - constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.logger = new Logger(3); this.e2ee = E2EE.withLocalStorage( { userId: () => Promise.resolve(Meteor.userId()), @@ -118,7 +81,7 @@ class E2E extends Emitter<{ ); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { - this.log(`${prevState} -> ${nextState}`); + logger.log(`${prevState} -> ${nextState}`); }); this.on('READY', async () => { @@ -144,18 +107,6 @@ class E2E extends Emitter<{ this.setState('NOT_STARTED'); } - log(...msg: unknown[]) { - this.logger.log('E2E', ...msg); - } - - error(...msg: unknown[]) { - this.logger.error('E2E', ...msg); - } - - warn(...msg: unknown[]) { - this.logger.warn('E2E', ...msg); - } - getState() { return this.state; } @@ -170,20 +121,20 @@ class E2E extends Emitter<{ } async onE2EEReady() { - this.log('startClient -> Done'); + logger.log('startClient -> Done'); this.initiateHandshake(); await this.handleAsyncE2EESuggestedKey(); - this.log('decryptSubscriptions'); + logger.log('decryptSubscriptions'); await this.decryptSubscriptions(); - this.log('decryptSubscriptions -> Done'); + logger.log('decryptSubscriptions -> Done'); await this.initiateKeyDistribution(); - this.log('initiateKeyDistribution -> Done'); + logger.log('initiateKeyDistribution -> Done'); this.observeSubscriptions(); - this.log('observing subscriptions'); + logger.log('observing subscriptions'); } async onSubscriptionChanged(sub: ISubscription) { - this.log('Subscription changed', sub); + logger.log('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -199,7 +150,7 @@ class E2E extends Emitter<{ await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - this.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + logger.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); await this.rejectSuggestedKey(sub.rid); } } @@ -237,7 +188,7 @@ class E2E extends Emitter<{ const excess = instatiated.difference(subscribed); if (excess.size) { - this.log('Unsubscribing from excess instances', excess); + logger.log('Unsubscribing from excess instances', excess); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -278,11 +229,11 @@ class E2E extends Emitter<{ } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - this.log('Imported valid E2E suggested key'); + logger.log('Imported valid E2E suggested key'); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - this.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + logger.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); await e2e.rejectSuggestedKey(sub.rid); } @@ -389,7 +340,7 @@ class E2E extends Emitter<{ return; } - this.log('startClient -> STARTED'); + logger.log('startClient -> STARTED'); this.started = true; @@ -453,7 +404,7 @@ class E2E extends Emitter<{ } async stopClient(): Promise { - this.log('-> Stop Client'); + logger.log('-> Stop Client'); this.closeAlert(); await this.e2ee.removeKeysFromLocalStorage(); @@ -479,7 +430,7 @@ class E2E extends Emitter<{ const result = await this.e2ee.loadKeysFromDB(); if (!result.isOk) { this.setState('ERROR'); - this.error('Error fetching RSA keys from DB: ', result.error); + logger.error('Error fetching RSA keys from DB: ', result.error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation throw result.error; @@ -493,7 +444,7 @@ class E2E extends Emitter<{ const res = await this.e2ee.loadKeys(keys); if (!res.isOk) { this.setState('ERROR'); - this.error('Error loading keys: ', res.error); + logger.error('Error loading keys: ', res.error); return; } this.privateKey = res.value; @@ -506,7 +457,7 @@ class E2E extends Emitter<{ if (!keys.isOk) { this.setState('ERROR'); - return this.error('Error creating keys: ', keys.error); + return logger.error('Error creating keys: ', keys.error); } this.publicKey = keys.value.publicKey; @@ -525,7 +476,7 @@ class E2E extends Emitter<{ return res.value; } this.setState('ERROR'); - return this.error('Error encoding private key: ', res.error); + return logger.error('Error encoding private key: ', res.error); } openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { @@ -682,7 +633,7 @@ class E2E extends Emitter<{ async decryptSubscription(subscriptionId: ISubscription['_id']): Promise { const e2eRoom = await this.getInstanceByRoomId(subscriptionId); - this.log('decryptSubscription ->', subscriptionId); + logger.log('decryptSubscription ->', subscriptionId); await e2eRoom?.decryptSubscription(); } @@ -835,7 +786,7 @@ class E2E extends Emitter<{ try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return this.error('Error providing group key to users: ', error); + return logger.error('Error providing group key to users: ', error); } }; diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 5d03f9f10aeb9..7b28edcbbeee7 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'; import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser'; import { e2e } from '../../../../lib/e2ee'; +import { logger } from '../../../../lib/e2ee/logger'; import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived'; import { Rooms } from '../../../../stores'; @@ -18,20 +19,18 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - e2e.log('Not logged in'); + logger.log('Not logged in'); return; } if (!window.crypto) { - e2e.error('No crypto support'); + logger.error('No crypto support'); return; } if (enabled && !adminEmbedded) { - e2e.log('E2E enabled starting client'); e2e.startClient(); } else { - e2e.log('E2E disabled'); e2e.setState('DISABLED'); e2e.closeAlert(); } @@ -48,12 +47,12 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - e2e.log('Not ready'); + logger.log('Not ready'); return; } if (listenersAttachedRef.current) { - e2e.log('Listeners already attached'); + logger.log('Listeners already attached'); return; } @@ -120,10 +119,10 @@ export const useE2EEncryption = () => { }); listenersAttachedRef.current = true; - e2e.log('Listeners attached'); + logger.log('Listeners attached'); return () => { - e2e.log('Not ready'); + logger.log('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; diff --git a/package.json b/package.json index 22b848882defe..a6b48033c41c6 100644 --- a/package.json +++ b/package.json @@ -1,84 +1,86 @@ { - "name": "rocket.chat", - "version": "7.10.0-develop", - "description": "Rocket.Chat Monorepo", - "main": "index.js", - "private": true, - "scripts": { - "build": "turbo run build", - "build:services": "turbo run build --filter=rocketchat-services...", - "build:ci": "turbo run build:ci", - "testunit": "turbo run testunit", - "typecheck": "turbo run typecheck", - "test-storybook": "turbo run test-storybook", - "dev": "turbo run dev --env-mode=loose --parallel --filter=@rocket.chat/meteor...", - "dsv": "turbo run dsv --env-mode=loose --filter=@rocket.chat/meteor...", - "tsv": "TEST_MODE=true yarn dsv", - "lint": "turbo run lint", - "storybook": "yarn workspace @rocket.chat/meteor run storybook", - "fuselage": "./fuselage.sh", - "fossify": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' ts-node scripts/fossify.ts" - }, - "devDependencies": { - "@changesets/cli": "^2.27.11", - "turbo": "~2.5.5" - }, - "workspaces": [ - "apps/*", - "packages/*", - "ee/apps/*", - "ee/packages/*", - "apps/meteor/ee/server/services" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/RocketChat/Rocket.Chat.git" - }, - "author": "", - "license": "MIT", - "bugs": { - "url": "https://github.com/RocketChat/Rocket.Chat/issues" - }, - "homepage": "https://github.com/RocketChat/Rocket.Chat#readme", - "engines": { - "yarn": "4.9.2", - "node": "22.16.0" - }, - "packageManager": "yarn@4.9.2", - "houston": { - "minTag": "0.55.0-rc.0", - "updateFiles": [ - "package.json", - "apps/meteor/app/utils/rocketchat.info" - ] - }, - "volta": { - "node": "22.16.0", - "yarn": "4.9.2" - }, - "resolutions": { - "@react-pdf/fns": "2.0.1", - "@react-pdf/font": "2.3.7", - "@react-pdf/image": "2.2.2", - "@react-pdf/layout": "3.6.3", - "@react-pdf/pdfkit": "3.0.2", - "@react-pdf/png-js": "2.2.0", - "@react-pdf/primitives": "3.0.1", - "@react-pdf/render": "3.2.7", - "@react-pdf/renderer": "3.1.14", - "@react-pdf/stylesheet": "4.1.8", - "@react-pdf/textkit": "4.2.0", - "@react-pdf/types": "2.3.4", - "@react-pdf/yoga": "4.1.2", - "minimist": "1.2.6", - "adm-zip": "0.5.9", - "underscore": "1.13.7", - "lodash": "4.17.21", - "mongodb": "6.10.0", - "cross-spawn": "7.0.6" - }, - "dependencies": { - "@types/stream-buffers": "^3.0.7", - "node-gyp": "^10.2.0" - } + "name": "rocket.chat", + "version": "7.10.0-develop", + "description": "Rocket.Chat Monorepo", + "main": "index.js", + "private": true, + "scripts": { + "build": "turbo run build", + "build:services": "turbo run build --filter=rocketchat-services...", + "build:ci": "turbo run build:ci", + "testunit": "turbo run testunit", + "typecheck": "turbo run typecheck", + "test-storybook": "turbo run test-storybook", + "dev": "turbo run dev --env-mode=loose --parallel --filter=@rocket.chat/meteor...", + "dsv": "turbo run dsv --env-mode=loose --filter=@rocket.chat/meteor...", + "tsv": "TEST_MODE=true yarn dsv", + "lint": "turbo run lint", + "storybook": "yarn workspace @rocket.chat/meteor run storybook", + "fuselage": "./fuselage.sh", + "fossify": "TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\"}' ts-node scripts/fossify.ts" + }, + "devDependencies": { + "@changesets/cli": "^2.27.11", + "@playwright/test": "^1.55.0", + "@types/node": "^24.3.0", + "turbo": "~2.5.5" + }, + "workspaces": [ + "apps/*", + "packages/*", + "ee/apps/*", + "ee/packages/*", + "apps/meteor/ee/server/services" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/RocketChat/Rocket.Chat.git" + }, + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/RocketChat/Rocket.Chat/issues" + }, + "homepage": "https://github.com/RocketChat/Rocket.Chat#readme", + "engines": { + "yarn": "4.9.2", + "node": "22.16.0" + }, + "packageManager": "yarn@4.9.2", + "houston": { + "minTag": "0.55.0-rc.0", + "updateFiles": [ + "package.json", + "apps/meteor/app/utils/rocketchat.info" + ] + }, + "volta": { + "node": "22.16.0", + "yarn": "4.9.2" + }, + "resolutions": { + "@react-pdf/fns": "2.0.1", + "@react-pdf/font": "2.3.7", + "@react-pdf/image": "2.2.2", + "@react-pdf/layout": "3.6.3", + "@react-pdf/pdfkit": "3.0.2", + "@react-pdf/png-js": "2.2.0", + "@react-pdf/primitives": "3.0.1", + "@react-pdf/render": "3.2.7", + "@react-pdf/renderer": "3.1.14", + "@react-pdf/stylesheet": "4.1.8", + "@react-pdf/textkit": "4.2.0", + "@react-pdf/types": "2.3.4", + "@react-pdf/yoga": "4.1.2", + "minimist": "1.2.6", + "adm-zip": "0.5.9", + "underscore": "1.13.7", + "lodash": "4.17.21", + "mongodb": "6.10.0", + "cross-spawn": "7.0.6" + }, + "dependencies": { + "@types/stream-buffers": "^3.0.7", + "node-gyp": "^10.2.0" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000000..daaff202c24f3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,90 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +const baseURL = 'http://localhost:3000'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'cd apps/meteor && meteor run', + // url: rootUrl.toString(), + // reuseExistingServer: !process.env.CI, + // name: 'Meteor', + // env: { + // ...process.env, + // TEST_MODE: 'true', + // DO_NOT_TRACK: '1', + // }, + // stdout: 'pipe', + // stderr: 'pipe', + // timeout: 2 * 60 * 1000, + // }, +}); diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts new file mode 100644 index 0000000000000..69bbd9e71e918 --- /dev/null +++ b/playwright/fixtures.ts @@ -0,0 +1,70 @@ +import { test as baseTest } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +export * from '@playwright/test'; + +const defaultAccount = { + username: 'jon.doe', + password: 'password123*R', +}; + +export const test = baseTest.extend<{}, { account: { username: string; password: string }; workerStorageState: string }>({ + // Use the same storage state for all tests in this worker. + storageState: ({ workerStorageState }, use) => use(workerStorageState), + account: [({}, use) => use(defaultAccount), { scope: 'worker' }], + + // Authenticate once per worker with a worker-scoped fixture. + workerStorageState: [ + async ({ browser, account }, use) => { + // Use parallelIndex as a unique identifier for each worker. + const { parallelIndex } = test.info(); + + const fileName = path.resolve(test.info().project.outputDir, `.auth/${account.username}/${parallelIndex}.json`); + + if (fs.existsSync(fileName)) { + // Reuse existing authentication state if any. + await use(fileName); + return; + } + + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ storageState: undefined }); + + // Acquire a unique account, for example create a new one. + // Alternatively, you can have a list of precreated accounts for testing. + // Make sure that accounts are unique, so that multiple team members + // can run tests at the same time without interference. + + // await page.goto('http://localhost:3000/'); + // await page.getByRole('link', { name: 'Create an account' }).click(); + // await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Jon Doe'); + // await page.getByRole('textbox', { name: 'Email' }).fill('jon@doe.com'); + // await page.getByRole('textbox', { name: 'Username' }).fill('jon.doe'); + // await page.getByRole('textbox', { name: 'Password', exact: true }).fill('password123*R'); + // await page.getByRole('textbox', { name: 'Confirm your password' }).fill('password123*R'); + // await page.getByRole('button', { name: 'Join your team' }).click(); + + // Perform authentication steps. Replace these actions with your own. + await page.goto('http://localhost:3000/login'); + await page.getByRole('textbox', { name: 'Email or username' }).fill(account.username); + await page.getByRole('textbox', { name: 'Password' }).fill(account.password); + await page.getByRole('button', { name: 'Login' }).click(); + // Wait until the page receives the cookies. + // + // Sometimes login flow sets cookies in the process of several redirects. + // Wait for the final URL to ensure that the cookies are actually set. + await page.waitForURL('http://localhost:3000/home'); + await page.waitForFunction(() => localStorage.getItem('Meteor.loginToken') !== null); + // Alternatively, you can wait until the page reaches a state where all cookies are set. + // await expect(page).toHaveTitle(/Login - Rocket.Chat/); + + // End of authentication steps. + + await page.context().storageState({ path: fileName, indexedDB: true }); + await page.close(); + await use(fileName); + }, + { scope: 'worker' }, + ], +}); diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000000000..cb1ade4c1e256 --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '../playwright/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/home'); +}); + +test('end-to-end encryption', async ({ page }) => { + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Home - Rocket.Chat/); + // Create an end-to-end encrypted channel + await page.getByRole('button', { name: 'Create Channel' }).click(); + await page.getByRole('textbox', { name: 'Channel Name' }).fill('encrypted-channel'); + await page.getByRole('button', { name: 'Create' }).click(); +}); diff --git a/yarn.lock b/yarn.lock index 0d0b5d4164150..0339aeba5fb6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5092,7 +5092,7 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:~1.55.0": +"@playwright/test@npm:^1.55.0, @playwright/test@npm:~1.55.0": version: 1.55.0 resolution: "@playwright/test@npm:1.55.0" dependencies: @@ -12352,6 +12352,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.3.0": + version: 24.3.0 + resolution: "@types/node@npm:24.3.0" + dependencies: + undici-types: "npm:~7.10.0" + checksum: 10/1331c2d0e9a512ac27a016b4df3eff92317e4603dbbbab31731275dff14d3a04847a50c5776cbf94f99ff4dedac0ba5f721dce8cea020d8eea5e21711fd964b0 + languageName: node + linkType: hard + "@types/node@npm:~22.16.5": version: 22.16.5 resolution: "@types/node@npm:22.16.5" @@ -32406,6 +32415,8 @@ __metadata: resolution: "rocket.chat@workspace:." dependencies: "@changesets/cli": "npm:^2.27.11" + "@playwright/test": "npm:^1.55.0" + "@types/node": "npm:^24.3.0" "@types/stream-buffers": "npm:^3.0.7" node-gyp: "npm:^10.2.0" turbo: "npm:~2.5.5" @@ -36058,6 +36069,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.10.0": + version: 7.10.0 + resolution: "undici-types@npm:7.10.0" + checksum: 10/1f3fe777937690ab8a7a7bccabc8fdf4b3171f4899b5a384fb5f3d6b56c4b5fec2a51fbf345c9dd002ff6716fd440a37fa8fdb0e13af8eca8889f25445875ba3 + languageName: node + linkType: hard + "undici@npm:^5.25.4, undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" From 1b6f1cb429a70847efef09f1c1631ff9b0ac40a9 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 29 Aug 2025 14:51:30 -0300 Subject: [PATCH 066/251] remove adapter libs --- apps/meteor/client/lib/e2ee/jwk.ts | 44 --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 24 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 23 +- .../views/account/security/EndToEnd.tsx | 5 +- .../views/room/E2EESetup/RoomE2EESetup.tsx | 3 +- apps/meteor/package.json | 1 - apps/meteor/tests/e2e/e2e-encryption.spec.ts | 14 +- .../page-objects/fragments/home-sidenav.ts | 2 - packages/e2ee-node/package.json | 40 --- packages/e2ee-node/scripts/postbuild.ts | 6 - .../e2ee-node/src/__tests__/.oxlintrc.json | 5 - .../e2ee-node/src/__tests__/codec.test.ts | 55 ---- packages/e2ee-node/src/codec.ts | 57 ---- packages/e2ee-node/src/index.ts | 29 -- packages/e2ee-node/tsconfig.build.json | 8 - packages/e2ee-node/tsconfig.json | 47 ---- packages/e2ee-node/vitest.config.ts | 7 - packages/e2ee-web/.oxlintrc.json | 21 -- packages/e2ee-web/package.json | 43 --- .../e2ee-web/src/__tests__/.oxlintrc.json | 5 - packages/e2ee-web/src/__tests__/e2ee.test.ts | 23 -- packages/e2ee-web/src/codec.ts | 66 ----- packages/e2ee-web/src/index.ts | 36 --- packages/e2ee-web/tsconfig.build.json | 8 - packages/e2ee-web/tsconfig.json | 47 ---- packages/e2ee-web/vitest.config.ts | 15 - packages/e2ee/.oxlintrc.json | 4 + packages/e2ee/HERMES.md | 103 ------- packages/e2ee/globals.d.ts | 4 + packages/e2ee/package.json | 9 +- .../src/__tests__/codec.test.ts | 0 .../src/__tests__/e2ee.test.ts | 4 +- packages/e2ee/src/codec.ts | 73 ++++- packages/e2ee/src/index.ts | 57 ++-- packages/e2ee/src/json.ts | 1 + packages/e2ee/src/json/parse.ts | 2 + packages/e2ee/src/jwk.ts | 67 +++++ packages/e2ee/tsconfig.json | 11 +- packages/e2ee/vitest.config.ts | 10 +- packages/ui-composer/package.json | 1 + yarn.lock | 263 +++++------------- 41 files changed, 298 insertions(+), 945 deletions(-) delete mode 100644 apps/meteor/client/lib/e2ee/jwk.ts delete mode 100644 packages/e2ee-node/package.json delete mode 100644 packages/e2ee-node/scripts/postbuild.ts delete mode 100644 packages/e2ee-node/src/__tests__/.oxlintrc.json delete mode 100644 packages/e2ee-node/src/__tests__/codec.test.ts delete mode 100644 packages/e2ee-node/src/codec.ts delete mode 100644 packages/e2ee-node/src/index.ts delete mode 100644 packages/e2ee-node/tsconfig.build.json delete mode 100644 packages/e2ee-node/tsconfig.json delete mode 100644 packages/e2ee-node/vitest.config.ts delete mode 100644 packages/e2ee-web/.oxlintrc.json delete mode 100644 packages/e2ee-web/package.json delete mode 100644 packages/e2ee-web/src/__tests__/.oxlintrc.json delete mode 100644 packages/e2ee-web/src/__tests__/e2ee.test.ts delete mode 100644 packages/e2ee-web/src/codec.ts delete mode 100644 packages/e2ee-web/src/index.ts delete mode 100644 packages/e2ee-web/tsconfig.build.json delete mode 100644 packages/e2ee-web/tsconfig.json delete mode 100644 packages/e2ee-web/vitest.config.ts delete mode 100644 packages/e2ee/HERMES.md create mode 100644 packages/e2ee/globals.d.ts rename packages/{e2ee-web => e2ee}/src/__tests__/codec.test.ts (100%) rename packages/{e2ee-node => e2ee}/src/__tests__/e2ee.test.ts (88%) create mode 100644 packages/e2ee/src/json.ts create mode 100644 packages/e2ee/src/json/parse.ts create mode 100644 packages/e2ee/src/jwk.ts diff --git a/apps/meteor/client/lib/e2ee/jwk.ts b/apps/meteor/client/lib/e2ee/jwk.ts deleted file mode 100644 index dc23edfb252b3..0000000000000 --- a/apps/meteor/client/lib/e2ee/jwk.ts +++ /dev/null @@ -1,44 +0,0 @@ -export const isJsonWebKey = (data: unknown): data is JsonWebKey => { - if (typeof data !== 'object' || data === null) { - return false; - } - - const obj = data as Record; - if (typeof obj.kty !== 'string') { - return false; - } - - switch (obj.kty) { - case 'oct': { - // Symmetric key: must have "k" - return typeof obj.k === 'string'; - } - case 'RSA': { - // RSA public/private key: must have "n" and "e" - return typeof obj.n === 'string' && typeof obj.e === 'string'; - } - case 'EC': { - // EC public/private key: must have "crv", "x", "y" - return typeof obj.crv === 'string' && typeof obj.x === 'string' && typeof obj.y === 'string'; - } - case 'OKP': { - // OKP (e.g., Ed25519): must have "crv" and "x" - return typeof obj.crv === 'string' && typeof obj.x === 'string'; - } - default: - return false; - } -}; - -export const isAesGcm = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A256GCM' } => - jwk.kty === 'oct' && jwk.alg === 'A256GCM'; -export const isAesCbc = (jwk: JsonWebKey): jwk is Omit & { kty: 'oct'; alg: 'A128CBC' } => - jwk.kty === 'oct' && jwk.alg === 'A128CBC'; - -export const parseJsonWebKey = (json: string): JsonWebKey => { - const obj = JSON.parse(json); - if (!isJsonWebKey(obj)) { - throw new Error('Invalid JSON Web Key'); - } - return obj; -}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index f0f5cf9aa743e..d0dd88c7b6de2 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,5 +1,6 @@ import { Base64 } from '@rocket.chat/base64'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; +import { Jwk } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; @@ -25,10 +26,9 @@ import { sha256HashFromArrayBuffer, createSha256HashFromText, generateAesGcmKey, - // generateAesCbcKey, + generateAesCbcKey, importAesGcmKey, } from './helper'; -import { isAesCbc, isAesGcm, parseJsonWebKey } from './jwk'; import { logger } from './logger'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; @@ -347,7 +347,7 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - const jwk = parseJsonWebKey(await this.exportSessionKey(key)); + const jwk = Jwk.parse(await this.exportSessionKey(key)); try { return await importAesGcmKey(jwk); } catch (error) { @@ -397,9 +397,9 @@ export class E2ERoom extends Emitter { this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } - const jwk = parseJsonWebKey(this.sessionKeyExportedString); + const jwk = Jwk.parse(this.sessionKeyExportedString); - if (isAesGcm(jwk)) { + if (Jwk.isAesGcm(jwk)) { try { const key = await importAesGcmKey(jwk); this.groupSessionKey = key; @@ -410,7 +410,7 @@ export class E2ERoom extends Emitter { } } - if (isAesCbc(jwk)) { + if (Jwk.isAesCbc(jwk)) { try { const key = await importAesCbcKey(jwk); this.groupSessionKey = key; @@ -422,9 +422,19 @@ export class E2ERoom extends Emitter { } } + /** + * @deprecated + * Use {@link createNewGroupKey} instead. + */ + async createOldGroupKey() { + this.groupSessionKey = await generateAesCbcKey(); + const sessionKeyExported = await exportJWKKey(this.groupSessionKey); + this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); + this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + } + async createNewGroupKey() { this.groupSessionKey = await generateAesGcmKey(); - const sessionKeyExported = await exportJWKKey(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index ac9be352f5c0f..8624a9bd1aede 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -4,7 +4,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; import type { LocalKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; -import E2EE from '@rocket.chat/e2ee-web'; +import E2EE from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import _ from 'lodash'; @@ -67,18 +67,15 @@ class E2E extends Emitter<{ this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = E2EE.withLocalStorage( - { - userId: () => Promise.resolve(Meteor.userId()), - fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), - persistKeys: (keys, force) => - sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { - ...keys, - force, - }), - }, - Accounts.storageLocation, - ); + this.e2ee = new E2EE({ + userId: () => Promise.resolve(Meteor.userId()), + fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), + persistKeys: (keys, force) => + sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { + ...keys, + force, + }), + }); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { logger.log(`${prevState} -> ${nextState}`); diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index e36ea8076bec6..95760f10e1fb1 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -1,7 +1,6 @@ import { Box, PasswordInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, Button, Divider } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useMethod, useTranslation, useLogout } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; -import { Accounts } from 'meteor/accounts-base'; import type { ComponentProps, ReactElement } from 'react'; import { useId, useCallback, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -13,8 +12,8 @@ const EndToEnd = (props: ComponentProps): ReactElement => { const dispatchToastMessage = useToastMessageDispatch(); const logout = useLogout(); - const publicKey = Accounts.storageLocation.getItem('public_key'); - const privateKey = Accounts.storageLocation.getItem('private_key'); + const publicKey = localStorage.getItem('public_key'); + const privateKey = localStorage.getItem('private_key'); const resetE2eKey = useMethod('e2e.resetOwnE2EKey'); diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index d07abb40a366e..bdd604989cb84 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -1,5 +1,4 @@ import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; -import { Accounts } from 'meteor/accounts-base'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,7 +17,7 @@ const RoomE2EESetup = () => { const e2eRoomState = useE2EERoomState(room._id); const { t } = useTranslation(); - const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); + const randomPassword = localStorage.getItem('e2e.randomPassword'); const onSavePassword = useCallback(() => { if (!randomPassword) { diff --git a/apps/meteor/package.json b/apps/meteor/package.json index e519f4a4fb943..135eaa11281da 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -251,7 +251,6 @@ "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/e2ee": "workspace:^", - "@rocket.chat/e2ee-web": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 5971dedddfb5f..9b0f99a2053d9 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -938,16 +938,10 @@ test.describe.fixme('e2ee room setup', () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); await expect(poHomeChannel.btnRoomSaveE2EEPassword).toBeVisible(); @@ -988,11 +982,7 @@ test.describe.fixme('e2ee room setup', () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index f1ef89c923995..e995a4721571c 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -232,8 +232,6 @@ export class HomeSidenav { // Expand and wait for checkbox to appear before clicking await this.advancedSettingsAccordion.click(); await expect(this.checkboxEncryption).toBeVisible(); - - await this.page.getByRole('button', { name: 'Advanced settings' }).click(); await this.page .locator('span') .filter({ hasText: /^Encrypted$/ }) diff --git a/packages/e2ee-node/package.json b/packages/e2ee-node/package.json deleted file mode 100644 index e66d98d29d8ad..0000000000000 --- a/packages/e2ee-node/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@rocket.chat/e2ee-node", - "private": true, - "version": "0.1.0", - "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", - "type": "module", - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "sideEffects": false, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", - "typecheck": "tsc -p tsconfig.json", - "lint": "eslint 'src/**/*.ts'", - "testunit": "vitest run" - }, - "dependencies": { - "@rocket.chat/e2ee": "workspace:*" - }, - "devDependencies": { - "@types/node": "~22.16.5", - "typescript": "~5.9.2", - "vitest": "4.0.0-beta.8" - }, - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/packages/e2ee-node/scripts/postbuild.ts b/packages/e2ee-node/scripts/postbuild.ts deleted file mode 100644 index be23ae772367e..0000000000000 --- a/packages/e2ee-node/scripts/postbuild.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { writeFile } from 'node:fs/promises'; - -// Create the CJS module entry point -// Require the E2EE class from the ESM module - -await writeFile('./dist/index.cjs', "module.exports = require('./index.js');\n"); diff --git a/packages/e2ee-node/src/__tests__/.oxlintrc.json b/packages/e2ee-node/src/__tests__/.oxlintrc.json deleted file mode 100644 index 329fae1135eb5..0000000000000 --- a/packages/e2ee-node/src/__tests__/.oxlintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "no-magic-numbers": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee-node/src/__tests__/codec.test.ts b/packages/e2ee-node/src/__tests__/codec.test.ts deleted file mode 100644 index e856df57b80f3..0000000000000 --- a/packages/e2ee-node/src/__tests__/codec.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { test, expect } from 'vitest'; -import KeyCodec from '../codec.ts'; -import type { JsonWebKey } from 'node:crypto'; - -const codec = new KeyCodec(); - -test('KeyCodec stringifyUint8Array', () => { - const data = new Uint8Array([1, 2, 3]); - const jsonString = codec.stringifyUint8Array(data); - expect(jsonString).toBe('{"$binary":"AQID"}'); -}); - -test('KeyCodec parseUint8Array', () => { - const jsonString = '{"$binary":"AQID"}'; - const data = codec.parseUint8Array(jsonString); - expect(data).toEqual(new Uint8Array([1, 2, 3])); -}); - -test('KeyCodec roundtrip (v1 structured encoding)', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - - const saltBuffer = codec.encodeSalt('salt-user-1'); - const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - const dec = await codec.decodePrivateKey(enc, masterKey); - expect(dec).toStrictEqual(privateJWK); -}); - -test('KeyCodec wrong password fails', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const salt1 = codec.encodeSalt('salt1'); - const masterKey = await codec.deriveMasterKey(salt1, 'correct'); - const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); -}); - -test('KeyCodec tamper detection (ciphertext)', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const salt = codec.encodeSalt('salt'); - const masterKey = await codec.deriveMasterKey(salt, 'pw'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - // Tamper with ctB64 - const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; - await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); -}); - -test('KeyCodec legacy roundtrip', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const privStr = JSON.stringify(privateJWK); - const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); - const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); - const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as JsonWebKey) : decAny; - expect(dec).toStrictEqual(privateJWK); -}); diff --git a/packages/e2ee-node/src/codec.ts b/packages/e2ee-node/src/codec.ts deleted file mode 100644 index 26f1b82de8df3..0000000000000 --- a/packages/e2ee-node/src/codec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { BaseKeyCodec } from '@rocket.chat/e2ee'; - -export default class NodeKeyCodec extends BaseKeyCodec { - constructor() { - super({ - exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), - getRandomUint8Array: (array) => crypto.getRandomValues(array), - getRandomUint16Array: (array) => crypto.getRandomValues(array), - getRandomUint32Array: (array) => crypto.getRandomValues(array), - importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), - importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateRsaOaepKeyPair: () => - crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt'], - ), - decodeBase64: (input) => Buffer.from(input, 'base64'), - encodeBase64: (bytes) => Buffer.from(bytes).toString('base64'), - decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), - encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), - encodeUtf8: (input) => { - const encoder = new TextEncoder(); - const dest = new Uint8Array(input.length); - encoder.encodeInto(input, dest); - return dest; - }, - decodeUtf8: (input) => new TextDecoder().decode(input), - deriveKeyWithPbkdf2: (salt, baseKey) => { - // The BaseKeyCodec expects an AES-CBC key with 256-bit length and ITERATIONS = 1000 for - // encode/decode operations. Previous implementation derived an AES-GCM key with 100000 iterations, - // which caused InvalidAccessError when using the key for AES-CBC encryption in tests. - return crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt, - iterations: 1000, - hash: 'SHA-256', - }, - baseKey, - { - name: 'AES-CBC', - length: 256, - }, - false, - ['encrypt', 'decrypt'], - ); - }, - encodeBinary: (input) => { - const encoder = new TextEncoder(); - const dest = new Uint8Array(input.length); - encoder.encodeInto(input, dest); - return dest; - }, - }); - } -} diff --git a/packages/e2ee-node/src/index.ts b/packages/e2ee-node/src/index.ts deleted file mode 100644 index b0995b988b7c4..0000000000000 --- a/packages/e2ee-node/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; -import NodeKeyCodec from './codec.ts'; - -class MemoryStorage implements KeyStorage { - private map = new Map(); - - load(keyName: string): Promise { - return Promise.resolve(this.map.get(keyName) ?? null); - } - store(keyName: string, value: string): Promise { - this.map.set(keyName, value); - return Promise.resolve(); - } - remove(keyName: string): Promise { - this.map.delete(keyName); - return Promise.resolve(); - } -} - -export default class NodeE2EE extends BaseE2EE { - constructor(keyStorage: KeyStorage, keyService: KeyService) { - super(new NodeKeyCodec(), keyStorage, keyService); - } - - static withMemoryStorage(keyService: KeyService): NodeE2EE { - const memoryStorage = new MemoryStorage(); - return new NodeE2EE(memoryStorage, keyService); - } -} diff --git a/packages/e2ee-node/tsconfig.build.json b/packages/e2ee-node/tsconfig.build.json deleted file mode 100644 index cf4278ae09d96..0000000000000 --- a/packages/e2ee-node/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/e2ee-node/tsconfig.json b/packages/e2ee-node/tsconfig.json deleted file mode 100644 index bb6712aa3b6df..0000000000000 --- a/packages/e2ee-node/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "compilerOptions": { - "composite": false, - "module": "node20", - "target": "ES2024", - "lib": ["ESNext"], - "types": ["node"], - - /** Modules */ - "rootDir": "src", - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - - /** Emit */ - "noEmit": true, - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - - /** Language and Environment */ - "libReplacement": false, - - /** Linting */ - "strict": true, - "erasableSyntaxOnly": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUncheckedSideEffectImports": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "isolatedModules": true, - "isolatedDeclarations": true, - "allowJs": false, - "esModuleInterop": false, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/e2ee-node/vitest.config.ts b/packages/e2ee-node/vitest.config.ts deleted file mode 100644 index 1c73ca943465f..0000000000000 --- a/packages/e2ee-node/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - fileParallelism: false, - }, -}); diff --git a/packages/e2ee-web/.oxlintrc.json b/packages/e2ee-web/.oxlintrc.json deleted file mode 100644 index d9b127d3e1971..0000000000000 --- a/packages/e2ee-web/.oxlintrc.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "categories": { - "correctness": "error", - "suspicious": "error", - "perf": "error", - "restriction": "error", - "style": "error", - "nursery": "error", - "pedantic": "error" - }, - "rules": { - "oxc/no-map-spread": "error", - "oxc/no-async-await": "allow", - "eslint/id-length": "allow", - "eslint/no-undef": "allow", - "eslint/sort-keys": "allow", - "eslint/no-plusplus": "allow", - "eslint/no-magic-numbers": "allow", - "unicorn/prefer-code-point": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee-web/package.json b/packages/e2ee-web/package.json deleted file mode 100644 index ec67960df3aee..0000000000000 --- a/packages/e2ee-web/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@rocket.chat/e2ee-web", - "private": true, - "version": "0.1.0", - "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", - "type": "module", - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "sideEffects": false, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", - "typecheck": "tsc -p tsconfig.json", - "lint": "oxlint --type-aware", - "testunit": "vitest run" - }, - "dependencies": { - "@rocket.chat/e2ee": "workspace:*" - }, - "devDependencies": { - "@vitest/browser": "4.0.0-beta.8", - "oxlint": "~1.12.0", - "oxlint-tsgolint": "~0.0.4", - "playwright": "~1.55.0", - "typescript": "~5.9.2", - "vitest": "4.0.0-beta.8" - }, - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/packages/e2ee-web/src/__tests__/.oxlintrc.json b/packages/e2ee-web/src/__tests__/.oxlintrc.json deleted file mode 100644 index 329fae1135eb5..0000000000000 --- a/packages/e2ee-web/src/__tests__/.oxlintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "no-magic-numbers": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee-web/src/__tests__/e2ee.test.ts b/packages/e2ee-web/src/__tests__/e2ee.test.ts deleted file mode 100644 index 919ca42251239..0000000000000 --- a/packages/e2ee-web/src/__tests__/e2ee.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from 'vitest'; -import E2EE from '../index.ts'; -import type { KeyService } from '@rocket.chat/e2ee'; - -const mockedKeyService: KeyService = { - fetchMyKeys: () => - Promise.resolve({ - public_key: 'mocked_public_key', - private_key: 'mocked_private_key', - }), - userId: () => Promise.resolve('mocked_user_id'), - persistKeys: () => Promise.resolve(), -}; - -test('E2EE createRandomPassword deterministic generation with 5 words', async () => { - // Inject custom word list by temporarily defining dynamic import. - // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. - // So we skip testing exact phrase (depends on wordList) and just assert shape. - const e2ee = E2EE.withMemoryStorage(mockedKeyService); - const pwd = await e2ee.createRandomPassword(5); - expect(pwd.split(' ').length).toBe(5); - expect(await e2ee.getRandomPassword()).toBe(pwd); -}); diff --git a/packages/e2ee-web/src/codec.ts b/packages/e2ee-web/src/codec.ts deleted file mode 100644 index 9996fe36a8aca..0000000000000 --- a/packages/e2ee-web/src/codec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { BaseKeyCodec } from '@rocket.chat/e2ee'; -import type { CryptoProvider } from '../../e2ee/dist/crypto'; - -const provider: CryptoProvider = { - exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), - getRandomUint8Array: (array) => crypto.getRandomValues(array), - getRandomUint16Array: (array) => crypto.getRandomValues(array), - getRandomUint32Array: (array) => crypto.getRandomValues(array), - importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), - importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateRsaOaepKeyPair: () => - crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ - 'encrypt', - 'decrypt', - ]), - - deriveKeyWithPbkdf2: (salt, baseKey) => - crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt, - iterations: 1000, - hash: 'SHA-256', - }, - baseKey, - { - name: 'AES-CBC', - length: 256, - }, - false, - ['encrypt', 'decrypt'], - ), - decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), - encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), - decodeBase64: (input) => { - const binaryString = atob(input); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - }, - encodeBase64: (input) => { - const bytes = new Uint8Array(input); - let binaryString = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binaryString += String.fromCharCode(bytes[i] ?? 0); - } - return btoa(binaryString); - }, - encodeBinary: (input) => { - const encoder = new TextEncoder(); - const dest = new Uint8Array(input.length); - encoder.encodeInto(input, dest); - return dest; - }, - encodeUtf8: (input) => new TextEncoder().encode(input), - decodeUtf8: (input) => new TextDecoder().decode(input), -}; - -export default class WebKeyCodec extends BaseKeyCodec { - constructor() { - super(provider); - } -} diff --git a/packages/e2ee-web/src/index.ts b/packages/e2ee-web/src/index.ts deleted file mode 100644 index 5c71426714dd8..0000000000000 --- a/packages/e2ee-web/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BaseE2EE, type KeyService, type KeyStorage } from '@rocket.chat/e2ee'; -import KeyCodec from './codec.ts'; - -export default class WebE2EE extends BaseE2EE { - constructor(keyStorage: KeyStorage, keyService: KeyService) { - super(new KeyCodec(), keyStorage, keyService); - } - - static withMemoryStorage(keyService: KeyService): WebE2EE { - const map = new Map(); - const memoryStorage: KeyStorage = { - load: (keyName) => Promise.resolve(map.get(keyName)), - remove: (keyName: string) => Promise.resolve(map.delete(keyName)), - store: (keyName: string, value: string): Promise => { - map.set(keyName, value); - return Promise.resolve(); - }, - }; - return new WebE2EE(memoryStorage, keyService); - } - - static withLocalStorage(keyService: KeyService, storage: Storage): WebE2EE { - const localStorage: KeyStorage = { - load: (keyName) => { - const item = storage.getItem(keyName); - return Promise.resolve(item); - }, - remove: (keyName) => Promise.resolve(storage.removeItem(keyName)), - store: (keyName: string, value: string): Promise => { - storage.setItem(keyName, value); - return Promise.resolve(); - }, - }; - return new WebE2EE(localStorage, keyService); - } -} diff --git a/packages/e2ee-web/tsconfig.build.json b/packages/e2ee-web/tsconfig.build.json deleted file mode 100644 index cf4278ae09d96..0000000000000 --- a/packages/e2ee-web/tsconfig.build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/e2ee-web/tsconfig.json b/packages/e2ee-web/tsconfig.json deleted file mode 100644 index 40f8682079d8a..0000000000000 --- a/packages/e2ee-web/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "compilerOptions": { - "composite": false, - "module": "preserve", - "target": "ES2024", - "lib": ["ESNext", "DOM"], - "types": [], - - /** Modules */ - "rootDir": "src", - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - - /** Emit */ - "noEmit": true, - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - - /** Language and Environment */ - "libReplacement": false, - - /** Linting */ - "strict": true, - "erasableSyntaxOnly": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUncheckedSideEffectImports": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "isolatedModules": true, - "isolatedDeclarations": true, - "allowJs": false, - "esModuleInterop": false, - "skipLibCheck": false - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/e2ee-web/vitest.config.ts b/packages/e2ee-web/vitest.config.ts deleted file mode 100644 index 71b9de22983f5..0000000000000 --- a/packages/e2ee-web/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - browser: { - fileParallelism: false, - screenshotFailures: false, - enabled: true, - provider: 'playwright', - headless: true, - // https://vitest.dev/guide/browser/playwright - instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], - }, - }, -}); diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index 54ae2293f2cb9..d9b127d3e1971 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -12,6 +12,10 @@ "oxc/no-map-spread": "error", "oxc/no-async-await": "allow", "eslint/id-length": "allow", + "eslint/no-undef": "allow", + "eslint/sort-keys": "allow", + "eslint/no-plusplus": "allow", + "eslint/no-magic-numbers": "allow", "unicorn/prefer-code-point": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/HERMES.md b/packages/e2ee/HERMES.md deleted file mode 100644 index 6e16c7a474ed5..0000000000000 --- a/packages/e2ee/HERMES.md +++ /dev/null @@ -1,103 +0,0 @@ -NaN -Infinity -undefined -parseInt -parseFloat -Object -Error -AggregateError -EvalError -RangeError -ReferenceError -SyntaxError -TypeError -URIError -TimeoutError -QuitError -String -BigInt -Function -Number -Boolean -Date -RegExp -Array -ArrayBuffer -DataView -Int8Array -Int16Array -Int32Array -Uint8Array -Uint8ClampedArray -Uint16Array -Uint32Array -Float32Array -Float64Array -BigInt64Array -BigUint64Array -Set -Map -WeakMap -WeakSet -Symbol -TextEncoder -Proxy -Math -JSON -Reflect -HermesInternal - concat - hasPromise - hasES6Class - enqueueJob - setPromiseRejectionTrackingHook - enablePromiseRejectionTracker - useEngineQueue - getEpilogues - getRuntimeProperties - ttiReached - ttrcReached - getFunctionLocation - getInstrumentedStats -DebuggerInternal - isDebuggerAttached - shouldPauseOnThrow -print -eval -isNaN -isFinite -escape -unescape -atob -btoa -decodeURI -decodeURIComponent -encodeURI -encodeURIComponent -globalThis -gc -Promise - length - name - caller - arguments - prototype - constructor - then - catch - finally - _l - _m - _n - resolve - all - allSettled - reject - race - any -quit -createHeapSnapshot -loadSegment -setTimeout -clearTimeout -setImmediate \ No newline at end of file diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts new file mode 100644 index 0000000000000..b404fe95abe49 --- /dev/null +++ b/packages/e2ee/globals.d.ts @@ -0,0 +1,4 @@ +interface JSON { + parse(text: string): unknown; + stringify(data: unknown): string; +} diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index d434dffeed72f..000289ae56d07 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -23,11 +23,10 @@ "testunit": "vitest run" }, "devDependencies": { - "@noble/ciphers": "~1.3.0", - "@noble/curves": "~1.9.7", - "@noble/hashes": "~1.8.0", - "oxlint": "~1.12.0", - "oxlint-tsgolint": "~0.0.4", + "@vitest/browser": "4.0.0-beta.8", + "oxlint": "~1.13.0", + "oxlint-tsgolint": "~0.1.2", + "playwright": "~1.55.0", "typescript": "~5.9.2", "vitest": "4.0.0-beta.8" }, diff --git a/packages/e2ee-web/src/__tests__/codec.test.ts b/packages/e2ee/src/__tests__/codec.test.ts similarity index 100% rename from packages/e2ee-web/src/__tests__/codec.test.ts rename to packages/e2ee/src/__tests__/codec.test.ts diff --git a/packages/e2ee-node/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts similarity index 88% rename from packages/e2ee-node/src/__tests__/e2ee.test.ts rename to packages/e2ee/src/__tests__/e2ee.test.ts index 919ca42251239..34823ea133869 100644 --- a/packages/e2ee-node/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -16,8 +16,8 @@ test('E2EE createRandomPassword deterministic generation with 5 words', async () // Inject custom word list by temporarily defining dynamic import. // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. - const e2ee = E2EE.withMemoryStorage(mockedKeyService); + const e2ee = new E2EE(mockedKeyService); const pwd = await e2ee.createRandomPassword(5); expect(pwd.split(' ').length).toBe(5); - expect(await e2ee.getRandomPassword()).toBe(pwd); + expect(e2ee.getRandomPassword()).toBe(pwd); }); diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts index 29f9b20e771b3..66a61cd7f4c84 100644 --- a/packages/e2ee/src/codec.ts +++ b/packages/e2ee/src/codec.ts @@ -1,3 +1,5 @@ +// oxlint-disable sort-keys +// oxlint-disable explicit-function-return-type import type { CryptoProvider } from './crypto.ts'; export interface JsonWebKeyPair { @@ -20,15 +22,73 @@ export interface EncodedPrivateKeyV1 { } export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union -export abstract class BaseKeyCodec { + +const provider = { + exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), + getRandomUint8Array: (array) => crypto.getRandomValues(array), + getRandomUint16Array: (array) => crypto.getRandomValues(array), + getRandomUint32Array: (array) => crypto.getRandomValues(array), + importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), + importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), + generateRsaOaepKeyPair: () => + crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ + 'encrypt', + 'decrypt', + ]), + + deriveKeyWithPbkdf2: (salt, baseKey) => + crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 1000, + hash: 'SHA-256', + }, + baseKey, + { + name: 'AES-CBC', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ), + decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), + encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), + decodeBase64: (input) => { + const binaryString = atob(input); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + }, + encodeBase64: (input) => { + const bytes = new Uint8Array(input); + let binaryString = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binaryString += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binaryString); + }, + encodeBinary: (input) => { + const encoder = new TextEncoder(); + const dest = new Uint8Array(input.length); + encoder.encodeInto(input, dest); + return dest; + }, + encodeUtf8: (input) => new TextEncoder().encode(input), + decodeUtf8: (input) => new TextDecoder().decode(input), +} satisfies CryptoProvider; +export default class KeyCodec { #crypto: CryptoProvider; get crypto(): CryptoProvider { return this.#crypto; } - constructor(crypto: CryptoProvider) { - this.#crypto = crypto; + constructor() { + this.#crypto = provider; } async generateRsaOaepKeyPair(): Promise { @@ -169,8 +229,11 @@ export abstract class BaseKeyCodec { } parseUint8Array(json: string): Uint8Array { - const { $binary } = JSON.parse(json); - const binaryString = this.#crypto.decodeBase64($binary); + const parsed = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null || !('$binary' in parsed) || typeof parsed.$binary !== 'string') { + throw new TypeError('Invalid JSON format, expected {"$binary":"..."}'); + } + const binaryString = this.#crypto.decodeBase64(parsed.$binary); return new Uint8Array(binaryString); } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 5a6b9a81a041b..f26c511b58bd1 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,15 +1,10 @@ import { type AsyncResult, err, ok } from './result.ts'; import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; import { toArrayBuffer, toString } from './binary.ts'; -import type { BaseKeyCodec } from './codec.ts'; +import KeyCodec from './codec.ts'; -export { BaseKeyCodec } from './codec.ts'; - -export interface KeyStorage { - load(keyName: string): Promise; - store(keyName: string, value: string): Promise; - remove(keyName: string): Promise; -} +export * as Jwk from './jwk.ts'; +import * as Jwk from './jwk.ts'; export interface KeyService { userId: () => Promise; @@ -35,22 +30,22 @@ export interface KeyPair { publicKey: JsonWebKey; } -export abstract class BaseE2EE { +export default class E2EE { #service: KeyService; - #storage: KeyStorage; - #codec: BaseKeyCodec; + #codec: KeyCodec; - constructor(codec: BaseKeyCodec, storage: KeyStorage, service: KeyService) { - this.#storage = storage; + constructor(service: KeyService) { this.#service = service; - this.#codec = codec; + this.#codec = new KeyCodec(); } async loadKeys({ public_key, private_key }: RemoteKeyPair): AsyncResult { try { - const res = await this.#codec.crypto.importRsaDecryptKey(JSON.parse(private_key)); - await this.#storage.store('public_key', public_key); - await this.#storage.store('private_key', private_key); + const res = await crypto.subtle.importKey('jwk', Jwk.parse(private_key), { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, [ + 'decrypt', + ]); + localStorage.setItem('public_key', public_key); + localStorage.setItem('private_key', private_key); return ok(res); } catch (error) { return err(new Error('Error loading keys', { cause: error })); @@ -149,14 +144,14 @@ export abstract class BaseE2EE { async setPublicKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); - await this.#storage.store('public_key', stringified); + localStorage.setItem('public_key', stringified); return exported; } async setPrivateKey(key: CryptoKey): Promise { const exported = await this.#codec.crypto.exportJsonWebKey(key); const stringified = JSON.stringify(exported); - return this.#storage.store('private_key', stringified); + localStorage.setItem('private_key', stringified); } async getMasterKey(password: string): AsyncResult { @@ -191,18 +186,18 @@ export abstract class BaseE2EE { } } - async getKeysFromLocalStorage(): Promise { - const public_key = await this.#storage.load('public_key'); - const private_key = await this.#storage.load('private_key'); + getKeysFromLocalStorage(): LocalKeyPair { + const public_key = localStorage.getItem('public_key'); + const private_key = localStorage.getItem('private_key'); return { private_key, public_key, }; } - async removeKeysFromLocalStorage(): Promise { - await this.#storage.remove('public_key'); - await this.#storage.remove('private_key'); + removeKeysFromLocalStorage(): void { + localStorage.removeItem('public_key'); + localStorage.removeItem('private_key'); } async getKeysFromService(): Promise { @@ -215,16 +210,16 @@ export abstract class BaseE2EE { return keys; } - async storeRandomPassword(randomPassword: string): Promise { - await this.#storage.store('e2e.random_password', randomPassword); + storeRandomPassword(randomPassword: string): void { + return localStorage.setItem('e2e.random_password', randomPassword); } - getRandomPassword(): Promise { - return this.#storage.load('e2e.random_password'); + getRandomPassword(): string | null { + return localStorage.getItem('e2e.random_password'); } - removeRandomPassword(): Promise { - return this.#storage.remove('e2e.random_password'); + removeRandomPassword(): void { + return localStorage.removeItem('e2e.random_password'); } async createRandomPassword(length: number): Promise { const randomPassword = await this.#codec.generateMnemonicPhrase(length); diff --git a/packages/e2ee/src/json.ts b/packages/e2ee/src/json.ts new file mode 100644 index 0000000000000..95c722f64e6c0 --- /dev/null +++ b/packages/e2ee/src/json.ts @@ -0,0 +1 @@ +export const parse = (json: string): unknown => JSON.parse(json); diff --git a/packages/e2ee/src/json/parse.ts b/packages/e2ee/src/json/parse.ts new file mode 100644 index 0000000000000..b431e34164d47 --- /dev/null +++ b/packages/e2ee/src/json/parse.ts @@ -0,0 +1,2 @@ +export const parse = (json: string): unknown => JSON.parse(json); +export const stringify = (data: unknown): string => JSON.stringify(data); diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts new file mode 100644 index 0000000000000..dbb367f6b31db --- /dev/null +++ b/packages/e2ee/src/jwk.ts @@ -0,0 +1,67 @@ +export interface Jwk { + kty: Kty; + alg: Alg; +} + +export const is = (data: unknown): data is Jwk => { + if (typeof data !== 'object') { + return false; + } + + if (data === null) { + return false; + } + + if ('kty' in data === false) { + return false; + } + + if (typeof data.kty !== 'string') { + return false; + } + + switch (data.kty) { + case 'oct': { + // Symmetric key: must have "k" + return 'k' in data && typeof data.k === 'string'; + } + case 'RSA': { + // RSA public/private key: must have "n" and "e" + return 'n' in data && typeof data.n === 'string' && 'e' in data && typeof data.e === 'string'; + } + case 'EC': { + // EC public/private key: must have "crv", "x", "y" + return ( + 'crv' in data && + typeof data.crv === 'string' && + 'x' in data && + typeof data.x === 'string' && + 'y' in data && + typeof data.y === 'string' + ); + } + case 'OKP': { + // OKP (e.g., Ed25519): must have "crv" and "x" + return 'crv' in data && typeof data.crv === 'string' && 'x' in data && typeof data.x === 'string'; + } + default: { + return false; + } + } +}; + +const isKey = + (kty: Kty, alg: Alg): ((jwk: Jwk) => jwk is Jwk) => + (jwk): jwk is Jwk => + jwk.kty === kty && jwk.alg === alg; + +export const isAesGcm = (jwk: Jwk): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); +export const isAesCbc = (jwk: Jwk): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); + +export const parse = (json: string): Jwk => { + const obj = JSON.parse(json); + if (!is(obj)) { + throw new Error('Invalid JSON Web Key'); + } + return obj; +}; diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json index 8ef0b9670df27..89a69fa4adb1e 100644 --- a/packages/e2ee/tsconfig.json +++ b/packages/e2ee/tsconfig.json @@ -3,13 +3,11 @@ "composite": false, "module": "preserve", "target": "ES2024", - "lib": ["ESNext", "WebWorker"], + "lib": ["ESNext", "DOM"], "types": [], - "typeRoots": [], /** Modules */ "rootDir": "src", - "moduleResolution": "bundler", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, @@ -19,7 +17,6 @@ "declaration": true, "declarationMap": true, "verbatimModuleSyntax": true, - "moduleDetection": "force", /** Language and Environment */ @@ -45,10 +42,6 @@ "esModuleInterop": false, "skipLibCheck": false }, - "typeAcquisition": { - "enable": false, - "disableFilenameBasedTypeAcquisition": true - }, - "include": ["src"], + "include": ["src", "globals.d.ts"], "exclude": [] } diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index 1c73ca943465f..71b9de22983f5 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -2,6 +2,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - fileParallelism: false, + browser: { + fileParallelism: false, + screenshotFailures: false, + enabled: true, + provider: 'playwright', + headless: true, + // https://vitest.dev/guide/browser/playwright + instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], + }, }, }); diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index a5cd00c2cfee4..2c5a49cae40e3 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -51,6 +51,7 @@ "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", + "ts-node": "~10.9.2", "typescript": "~5.9.2", "webpack": "~5.99.9" }, diff --git a/yarn.lock b/yarn.lock index f8ea1f9393b37..cda65c0d2d27a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4171,23 +4171,7 @@ __metadata: languageName: node linkType: hard -"@noble/ciphers@npm:~1.3.0": - version: 1.3.0 - resolution: "@noble/ciphers@npm:1.3.0" - checksum: 10/051660051e3e9e2ca5fb9dece2885532b56b7e62946f89afa7284a0fb8bc02e2bd1c06554dba68162ff42d295b54026456084198610f63c296873b2f1cd7a586 - languageName: node - linkType: hard - -"@noble/curves@npm:~1.9.7": - version: 1.9.7 - resolution: "@noble/curves@npm:1.9.7" - dependencies: - "@noble/hashes": "npm:1.8.0" - checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 - languageName: node - linkType: hard - -"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.1.5, @noble/hashes@npm:~1.8.0": +"@noble/hashes@npm:^1.1.5": version: 1.8.0 resolution: "@noble/hashes@npm:1.8.0" checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e @@ -4774,142 +4758,100 @@ __metadata: languageName: node linkType: hard -"@oxlint-tsgolint/darwin-arm64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.0.2" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/darwin-arm64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.0.4" +"@oxlint-tsgolint/darwin-arm64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.1.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/darwin-x64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.0.2" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/darwin-x64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.0.4" +"@oxlint-tsgolint/darwin-x64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.1.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-arm64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.0.2" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-arm64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.0.4" +"@oxlint-tsgolint/linux-arm64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.1.2" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-x64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.0.2" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-x64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.0.4" +"@oxlint-tsgolint/linux-x64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.1.2" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-arm64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.0.2" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-arm64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.0.4" +"@oxlint-tsgolint/win32-arm64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.1.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-x64@npm:0.0.2": - version: 0.0.2 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.0.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-x64@npm:0.0.4": - version: 0.0.4 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.0.4" +"@oxlint-tsgolint/win32-x64@npm:0.1.2": + version: 0.1.2 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.1.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint/darwin-arm64@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/darwin-arm64@npm:1.12.0" +"@oxlint/darwin-arm64@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/darwin-arm64@npm:1.13.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/darwin-x64@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/darwin-x64@npm:1.12.0" +"@oxlint/darwin-x64@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/darwin-x64@npm:1.13.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/linux-arm64-gnu@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/linux-arm64-gnu@npm:1.12.0" +"@oxlint/linux-arm64-gnu@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.13.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-arm64-musl@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/linux-arm64-musl@npm:1.12.0" +"@oxlint/linux-arm64-musl@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.13.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/linux-x64-gnu@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/linux-x64-gnu@npm:1.12.0" +"@oxlint/linux-x64-gnu@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.13.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-x64-musl@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/linux-x64-musl@npm:1.12.0" +"@oxlint/linux-x64-musl@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/linux-x64-musl@npm:1.13.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/win32-arm64@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/win32-arm64@npm:1.12.0" +"@oxlint/win32-arm64@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/win32-arm64@npm:1.13.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/win32-x64@npm:1.12.0": - version: 1.12.0 - resolution: "@oxlint/win32-x64@npm:1.12.0" +"@oxlint/win32-x64@npm:1.13.0": + version: 1.13.0 + resolution: "@oxlint/win32-x64@npm:1.13.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -7413,45 +7355,19 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/e2ee-node@workspace:packages/e2ee-node": +"@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": version: 0.0.0-use.local - resolution: "@rocket.chat/e2ee-node@workspace:packages/e2ee-node" - dependencies: - "@rocket.chat/e2ee": "workspace:*" - "@types/node": "npm:~22.16.5" - typescript: "npm:~5.9.2" - vitest: "npm:4.0.0-beta.8" - languageName: unknown - linkType: soft - -"@rocket.chat/e2ee-web@workspace:^, @rocket.chat/e2ee-web@workspace:packages/e2ee-web": - version: 0.0.0-use.local - resolution: "@rocket.chat/e2ee-web@workspace:packages/e2ee-web" + resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: - "@rocket.chat/e2ee": "workspace:*" "@vitest/browser": "npm:4.0.0-beta.8" - oxlint: "npm:~1.12.0" - oxlint-tsgolint: "npm:~0.0.4" + oxlint: "npm:~1.13.0" + oxlint-tsgolint: "npm:~0.1.2" playwright: "npm:~1.55.0" typescript: "npm:~5.9.2" vitest: "npm:4.0.0-beta.8" languageName: unknown linkType: soft -"@rocket.chat/e2ee@workspace:*, @rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": - version: 0.0.0-use.local - resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" - dependencies: - "@noble/ciphers": "npm:~1.3.0" - "@noble/curves": "npm:~1.9.7" - "@noble/hashes": "npm:~1.8.0" - oxlint: "npm:~1.12.0" - oxlint-tsgolint: "npm:~0.0.4" - typescript: "npm:~5.9.2" - vitest: "npm:4.0.0-beta.8" - languageName: unknown - linkType: soft - "@rocket.chat/emitter@npm:~0.31.25": version: 0.31.25 resolution: "@rocket.chat/emitter@npm:0.31.25" @@ -8088,7 +8004,6 @@ __metadata: "@rocket.chat/cron": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/e2ee": "workspace:^" - "@rocket.chat/e2ee-web": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" @@ -9257,6 +9172,7 @@ __metadata: react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" + ts-node: "npm:~10.9.2" typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: @@ -28586,45 +28502,16 @@ __metadata: languageName: node linkType: hard -"oxlint-tsgolint@npm:>=0.0.1": - version: 0.0.2 - resolution: "oxlint-tsgolint@npm:0.0.2" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.0.2" - "@oxlint-tsgolint/darwin-x64": "npm:0.0.2" - "@oxlint-tsgolint/linux-arm64": "npm:0.0.2" - "@oxlint-tsgolint/linux-x64": "npm:0.0.2" - "@oxlint-tsgolint/win32-arm64": "npm:0.0.2" - "@oxlint-tsgolint/win32-x64": "npm:0.0.2" - dependenciesMeta: - "@oxlint-tsgolint/darwin-arm64": - optional: true - "@oxlint-tsgolint/darwin-x64": - optional: true - "@oxlint-tsgolint/linux-arm64": - optional: true - "@oxlint-tsgolint/linux-x64": - optional: true - "@oxlint-tsgolint/win32-arm64": - optional: true - "@oxlint-tsgolint/win32-x64": - optional: true - bin: - tsgolint: bin/tsgolint.js - checksum: 10/8d0b0b60fcbd480098ab666d695f584e058398d5cccaa88a9ffbc5431654558ab0848b5116386cc30e03211a163bbb586d289f7f41a061b77ad7fdd8004d58a5 - languageName: node - linkType: hard - -"oxlint-tsgolint@npm:~0.0.4": - version: 0.0.4 - resolution: "oxlint-tsgolint@npm:0.0.4" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.0.4" - "@oxlint-tsgolint/darwin-x64": "npm:0.0.4" - "@oxlint-tsgolint/linux-arm64": "npm:0.0.4" - "@oxlint-tsgolint/linux-x64": "npm:0.0.4" - "@oxlint-tsgolint/win32-arm64": "npm:0.0.4" - "@oxlint-tsgolint/win32-x64": "npm:0.0.4" +"oxlint-tsgolint@npm:~0.1.2": + version: 0.1.2 + resolution: "oxlint-tsgolint@npm:0.1.2" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.1.2" + "@oxlint-tsgolint/darwin-x64": "npm:0.1.2" + "@oxlint-tsgolint/linux-arm64": "npm:0.1.2" + "@oxlint-tsgolint/linux-x64": "npm:0.1.2" + "@oxlint-tsgolint/win32-arm64": "npm:0.1.2" + "@oxlint-tsgolint/win32-x64": "npm:0.1.2" dependenciesMeta: "@oxlint-tsgolint/darwin-arm64": optional: true @@ -28640,23 +28527,24 @@ __metadata: optional: true bin: tsgolint: bin/tsgolint.js - checksum: 10/af3824231c82fc5f6d12cfcb12fea48e902ce6d229b8e10f27e61ccb2e3fb61bcf2cc8d362f78575a2a8f772532a0e1b0b8b4715ab4eea3ed97c9c6746de2d0c + checksum: 10/5815575524c59227ba11cbce2a3f4b474a98c74cc1bd5066bb9e208f36f91bd283a2840712cdbdaaf12034bc4e9386665c4937896a7695c739eb05f769bf113b languageName: node linkType: hard -"oxlint@npm:~1.12.0": - version: 1.12.0 - resolution: "oxlint@npm:1.12.0" - dependencies: - "@oxlint/darwin-arm64": "npm:1.12.0" - "@oxlint/darwin-x64": "npm:1.12.0" - "@oxlint/linux-arm64-gnu": "npm:1.12.0" - "@oxlint/linux-arm64-musl": "npm:1.12.0" - "@oxlint/linux-x64-gnu": "npm:1.12.0" - "@oxlint/linux-x64-musl": "npm:1.12.0" - "@oxlint/win32-arm64": "npm:1.12.0" - "@oxlint/win32-x64": "npm:1.12.0" - oxlint-tsgolint: "npm:>=0.0.1" +"oxlint@npm:~1.13.0": + version: 1.13.0 + resolution: "oxlint@npm:1.13.0" + dependencies: + "@oxlint/darwin-arm64": "npm:1.13.0" + "@oxlint/darwin-x64": "npm:1.13.0" + "@oxlint/linux-arm64-gnu": "npm:1.13.0" + "@oxlint/linux-arm64-musl": "npm:1.13.0" + "@oxlint/linux-x64-gnu": "npm:1.13.0" + "@oxlint/linux-x64-musl": "npm:1.13.0" + "@oxlint/win32-arm64": "npm:1.13.0" + "@oxlint/win32-x64": "npm:1.13.0" + peerDependencies: + oxlint-tsgolint: ">=0.0.4" dependenciesMeta: "@oxlint/darwin-arm64": optional: true @@ -28674,12 +28562,13 @@ __metadata: optional: true "@oxlint/win32-x64": optional: true + peerDependenciesMeta: oxlint-tsgolint: optional: true bin: oxc_language_server: bin/oxc_language_server oxlint: bin/oxlint - checksum: 10/3f9feefdeabd77ae35e1c69e2e66a7cf428643cf6a23e7f9a4db04d07d4300c6f7048a50506988ac89e0c5b5031504bd535cd978a729029158ad56a9291aa9c5 + checksum: 10/af576f35f76a29c368cb4a199d694bf5fd10adaf0f7c1b624db23889f5ebadeea0d9081a298f1ee7be92eea79fa96f07e390133b8813b4af420a0642533e11b6 languageName: node linkType: hard @@ -35480,7 +35369,7 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:^10.9.2": +"ts-node@npm:^10.9.2, ts-node@npm:~10.9.2": version: 10.9.2 resolution: "ts-node@npm:10.9.2" dependencies: From a198155e81168256f18beda13562cd09a3bf26f6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 29 Aug 2025 15:40:54 -0300 Subject: [PATCH 067/251] add jwk tests --- packages/e2ee/src/__tests__/jwk.spec.ts | 18 ++++++++++++++++++ packages/e2ee/src/jwk.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 packages/e2ee/src/__tests__/jwk.spec.ts diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts new file mode 100644 index 0000000000000..49609d9fff215 --- /dev/null +++ b/packages/e2ee/src/__tests__/jwk.spec.ts @@ -0,0 +1,18 @@ +import * as Jwk from '../jwk.ts'; +import { expect, test } from 'vitest'; + +test('AES-CBC', async () => { + const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); + const jwk = await crypto.subtle.exportKey('jwk', key); + + expect(Jwk.is(jwk)).toBe(true); + expect(Jwk.isAesCbc(jwk)).toBe(true); +}); + +test('AES-GCM', async () => { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const jwk = await crypto.subtle.exportKey('jwk', key); + + expect(Jwk.is(jwk)).toBe(true); + expect(Jwk.isAesGcm(jwk)).toBe(true); +}); diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts index dbb367f6b31db..de29802f7f746 100644 --- a/packages/e2ee/src/jwk.ts +++ b/packages/e2ee/src/jwk.ts @@ -12,7 +12,7 @@ export const is = (data: unknown): data is Jwk => { return false; } - if ('kty' in data === false) { + if (!('kty' in data)) { return false; } @@ -61,7 +61,7 @@ export const isAesCbc = (jwk: Jwk): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', export const parse = (json: string): Jwk => { const obj = JSON.parse(json); if (!is(obj)) { - throw new Error('Invalid JSON Web Key'); + throw new TypeError('Invalid JSON Web Key'); } return obj; }; From 6b26b4dd8edde5f8d7ae48ca2f53b084d38b3cd8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 29 Aug 2025 15:44:38 -0300 Subject: [PATCH 068/251] remove json helpers --- packages/e2ee/src/__tests__/jwk.spec.ts | 2 -- packages/e2ee/src/json.ts | 1 - packages/e2ee/src/json/parse.ts | 2 -- packages/e2ee/src/jwk.ts | 6 +++--- 4 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 packages/e2ee/src/json.ts delete mode 100644 packages/e2ee/src/json/parse.ts diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts index 49609d9fff215..bce321bd7ce6a 100644 --- a/packages/e2ee/src/__tests__/jwk.spec.ts +++ b/packages/e2ee/src/__tests__/jwk.spec.ts @@ -12,7 +12,5 @@ test('AES-CBC', async () => { test('AES-GCM', async () => { const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const jwk = await crypto.subtle.exportKey('jwk', key); - - expect(Jwk.is(jwk)).toBe(true); expect(Jwk.isAesGcm(jwk)).toBe(true); }); diff --git a/packages/e2ee/src/json.ts b/packages/e2ee/src/json.ts deleted file mode 100644 index 95c722f64e6c0..0000000000000 --- a/packages/e2ee/src/json.ts +++ /dev/null @@ -1 +0,0 @@ -export const parse = (json: string): unknown => JSON.parse(json); diff --git a/packages/e2ee/src/json/parse.ts b/packages/e2ee/src/json/parse.ts deleted file mode 100644 index b431e34164d47..0000000000000 --- a/packages/e2ee/src/json/parse.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const parse = (json: string): unknown => JSON.parse(json); -export const stringify = (data: unknown): string => JSON.stringify(data); diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts index de29802f7f746..f846a7310ceef 100644 --- a/packages/e2ee/src/jwk.ts +++ b/packages/e2ee/src/jwk.ts @@ -51,12 +51,12 @@ export const is = (data: unknown): data is Jwk => { }; const isKey = - (kty: Kty, alg: Alg): ((jwk: Jwk) => jwk is Jwk) => + (kty: Kty, alg: Alg): ((jwk: Partial) => jwk is Jwk) => (jwk): jwk is Jwk => jwk.kty === kty && jwk.alg === alg; -export const isAesGcm = (jwk: Jwk): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); -export const isAesCbc = (jwk: Jwk): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); +export const isAesGcm = (jwk: Partial): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); +export const isAesCbc = (jwk: Partial): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); export const parse = (json: string): Jwk => { const obj = JSON.parse(json); From a6b8e370f0b6bb6a887a2a40213e90e65f3b0cd8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 30 Aug 2025 10:44:36 -0300 Subject: [PATCH 069/251] add base64 polyfill --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 10 +- packages/e2ee/.oxlintrc.json | 5 +- packages/e2ee/globals.d.ts | 68 +++++ packages/e2ee/package.json | 10 +- packages/e2ee/src/__tests__/base64.test.ts | 14 + packages/e2ee/src/__tests__/codec.test.ts | 54 ---- packages/e2ee/src/__tests__/e2ee.test.ts | 6 +- packages/e2ee/src/__tests__/jwk.spec.ts | 19 ++ packages/e2ee/src/base64.ts | 34 +++ packages/e2ee/src/codec.ts | 239 --------------- packages/e2ee/src/crypto.ts | 139 +-------- packages/e2ee/src/derive.ts | 28 ++ packages/e2ee/src/index.ts | 43 +-- packages/e2ee/src/jwk.ts | 3 + packages/e2ee/tsconfig.build.json | 1 - packages/e2ee/turbo.json | 10 + packages/e2ee/vitest.config.ts | 1 - yarn.lock | 274 +++++++++--------- 18 files changed, 360 insertions(+), 598 deletions(-) create mode 100644 packages/e2ee/src/__tests__/base64.test.ts delete mode 100644 packages/e2ee/src/__tests__/codec.test.ts create mode 100644 packages/e2ee/src/base64.ts delete mode 100644 packages/e2ee/src/codec.ts create mode 100644 packages/e2ee/src/derive.ts create mode 100644 packages/e2ee/turbo.json diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 8624a9bd1aede..78e57de14fddb 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -71,10 +71,12 @@ class E2E extends Emitter<{ userId: () => Promise.resolve(Meteor.userId()), fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), persistKeys: (keys, force) => - sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { - ...keys, - force, - }), + sdk.rest + .post('/v1/e2e.setUserPublicAndPrivateKeys', { + ...keys, + force, + }) + .then((response) => response ?? undefined), }); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index d9b127d3e1971..aac49bd60ea1b 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -1,4 +1,7 @@ { + "env": { + "shared-node-browser": true + }, "categories": { "correctness": "error", "suspicious": "error", @@ -16,6 +19,6 @@ "eslint/sort-keys": "allow", "eslint/no-plusplus": "allow", "eslint/no-magic-numbers": "allow", - "unicorn/prefer-code-point": "allow" + "eslint/no-ternary": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts index b404fe95abe49..c0a93d24b74d0 100644 --- a/packages/e2ee/globals.d.ts +++ b/packages/e2ee/globals.d.ts @@ -2,3 +2,71 @@ interface JSON { parse(text: string): unknown; stringify(data: unknown): string; } + +interface Uint8ArrayConstructor { + /** + * Creates a new `Uint8Array` from a base64-encoded string. + * @param string The base64-encoded string. + * @param options If provided, specifies the alphabet and handling of the last chunk. + * @returns A new `Uint8Array` instance. + * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last + * chunk is inconsistent with the `lastChunkHandling` option. + */ + fromBase64( + string: string, + options?: { + alphabet?: 'base64' | 'base64url'; + lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial'; + }, + ): Uint8Array; + + /** + * Creates a new `Uint8Array` from a base16-encoded string. + * @returns A new `Uint8Array` instance. + */ + fromHex(string: string): Uint8Array; +} + +interface Uint8Array { + /** + * Converts the `Uint8Array` to a base64-encoded string. + * @param options If provided, sets the alphabet and padding behavior used. + * @returns A base64-encoded string. + */ + toBase64(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; + + /** + * Sets the `Uint8Array` from a base64-encoded string. + * @param string The base64-encoded string. + * @param options If provided, specifies the alphabet and handling of the last chunk. + * @returns An object containing the number of bytes read and written. + * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last + * chunk is inconsistent with the `lastChunkHandling` option. + */ + setFromBase64( + string: string, + options?: { + alphabet?: 'base64' | 'base64url'; + lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial'; + }, + ): { + read: number; + written: number; + }; + + /** + * Converts the `Uint8Array` to a base16-encoded string. + * @returns A base16-encoded string. + */ + toHex(): string; + + /** + * Sets the `Uint8Array` from a base16-encoded string. + * @param string The base16-encoded string. + * @returns An object containing the number of bytes read and written. + */ + setFromHex(string: string): { + read: number; + written: number; + }; +} diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 000289ae56d07..cdf9234cf5c86 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -23,12 +23,12 @@ "testunit": "vitest run" }, "devDependencies": { - "@vitest/browser": "4.0.0-beta.8", - "oxlint": "~1.13.0", - "oxlint-tsgolint": "~0.1.2", - "playwright": "~1.55.0", + "@vitest/browser": "4.0.0-beta.9", + "oxlint": "~1.14.0", + "oxlint-tsgolint": "~0.1.5", + "playwright": "^1.55.0", "typescript": "~5.9.2", - "vitest": "4.0.0-beta.8" + "vitest": "4.0.0-beta.9" }, "license": "MIT", "publishConfig": { diff --git a/packages/e2ee/src/__tests__/base64.test.ts b/packages/e2ee/src/__tests__/base64.test.ts new file mode 100644 index 0000000000000..6fc10e475c950 --- /dev/null +++ b/packages/e2ee/src/__tests__/base64.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; +import { parseUint8Array, stringifyUint8Array, toBase64, fromBase64 } from '../base64.ts'; + +const cases = [ + ['', new Uint8Array([])], + ['AQID', new Uint8Array([1, 2, 3])], +] satisfies [string: string, uint8array: Uint8Array][]; + +test.for(cases)('fromBase64', ([string, uint8array]) => { + expect(fromBase64(string)).toEqual(uint8array); + expect(toBase64(uint8array)).toBe(string); + expect(stringifyUint8Array(uint8array)).toBe(`{"$binary":"${string}"}`); + expect(parseUint8Array(`{"$binary":"${string}"}`)).toEqual(uint8array); +}); diff --git a/packages/e2ee/src/__tests__/codec.test.ts b/packages/e2ee/src/__tests__/codec.test.ts deleted file mode 100644 index 496d06bc988ef..0000000000000 --- a/packages/e2ee/src/__tests__/codec.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from 'vitest'; -import KeyCodec from '../codec.ts'; - -const codec = new KeyCodec(); - -test('KeyCodec stringifyUint8Array', () => { - const data = new Uint8Array([1, 2, 3]); - const jsonString = codec.stringifyUint8Array(data); - expect(jsonString).toBe('{"$binary":"AQID"}'); -}); - -test('KeyCodec parseUint8Array', () => { - const jsonString = '{"$binary":"AQID"}'; - const data = codec.parseUint8Array(jsonString); - expect(data).toEqual(new Uint8Array([1, 2, 3])); -}); - -test('KeyCodec roundtrip (v1 structured encoding)', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - - const saltBuffer = codec.encodeSalt('salt-user-1'); - const masterKey = await codec.deriveMasterKey(saltBuffer, 'pass123'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - const dec = await codec.decodePrivateKey(enc, masterKey); - expect(dec).toStrictEqual(privateJWK); -}); - -test('KeyCodec wrong password fails', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const salt1 = codec.encodeSalt('salt1'); - const masterKey = await codec.deriveMasterKey(salt1, 'correct'); - const masterKey2 = await codec.deriveMasterKey(salt1, 'incorrect'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - await expect(codec.decodePrivateKey(enc, masterKey2)).rejects.toThrow(); -}); - -test('KeyCodec tamper detection (ciphertext)', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const salt = codec.encodeSalt('salt'); - const masterKey = await codec.deriveMasterKey(salt, 'pw'); - const enc = await codec.encodePrivateKey(privateJWK, masterKey); - // Tamper with ctB64 - const mutated = { ...enc, ctB64: enc.ctB64.slice(0, -2) + 'AA' }; - await expect(codec.decodePrivateKey(mutated, masterKey)).rejects.toThrow(); -}); - -test('KeyCodec legacy roundtrip', async () => { - const { privateJWK } = await codec.generateRsaOaepKeyPair(); - const privStr = JSON.stringify(privateJWK); - const blob = await codec.legacyEncrypt(privStr, 'pw', 'salt'); - const decAny = await codec.legacyDecrypt(blob, 'pw', 'salt'); - const dec = typeof decAny === 'string' ? (JSON.parse(decAny) as JsonWebKey) : decAny; - expect(dec).toStrictEqual(privateJWK); -}); diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 34823ea133869..614e6d5639d77 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,8 +1,7 @@ import { expect, test } from 'vitest'; import E2EE from '../index.ts'; -import type { KeyService } from '@rocket.chat/e2ee'; -const mockedKeyService: KeyService = { +const e2ee = new E2EE({ fetchMyKeys: () => Promise.resolve({ public_key: 'mocked_public_key', @@ -10,13 +9,12 @@ const mockedKeyService: KeyService = { }), userId: () => Promise.resolve('mocked_user_id'), persistKeys: () => Promise.resolve(), -}; +}); test('E2EE createRandomPassword deterministic generation with 5 words', async () => { // Inject custom word list by temporarily defining dynamic import. // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. // So we skip testing exact phrase (depends on wordList) and just assert shape. - const e2ee = new E2EE(mockedKeyService); const pwd = await e2ee.createRandomPassword(5); expect(pwd.split(' ').length).toBe(5); expect(e2ee.getRandomPassword()).toBe(pwd); diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts index bce321bd7ce6a..506f6664f8ba5 100644 --- a/packages/e2ee/src/__tests__/jwk.spec.ts +++ b/packages/e2ee/src/__tests__/jwk.spec.ts @@ -12,5 +12,24 @@ test('AES-CBC', async () => { test('AES-GCM', async () => { const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const jwk = await crypto.subtle.exportKey('jwk', key); + expect(Jwk.is(jwk)).toBe(true); expect(Jwk.isAesGcm(jwk)).toBe(true); }); + +test('RSA-OAEP', async () => { + const key = await crypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ); + + const publicJwk = await crypto.subtle.exportKey('jwk', key.publicKey); + + expect(Jwk.is(publicJwk)).toBe(true); + expect(Jwk.isRsaOaep(publicJwk)).toBe(true); + + const privateJwk = await crypto.subtle.exportKey('jwk', key.privateKey); + + expect(Jwk.is(privateJwk)).toBe(true); + expect(Jwk.isRsaOaep(privateJwk)).toBe(true); +}); diff --git a/packages/e2ee/src/base64.ts b/packages/e2ee/src/base64.ts new file mode 100644 index 0000000000000..30cb26851f7fc --- /dev/null +++ b/packages/e2ee/src/base64.ts @@ -0,0 +1,34 @@ +export const fromBase64: (string: string) => Uint8Array = + typeof Uint8Array.fromBase64 === 'function' + ? Uint8Array.fromBase64 + : (string) => { + const binary = atob(string); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.codePointAt(i) ?? 0; + } + return bytes; + }; + +export const toBase64: (input: Uint8Array) => string = + typeof Uint8Array.prototype.toBase64 === 'function' + ? (input) => Uint8Array.prototype.toBase64.call(input) + : (input) => { + const CHUNK_SIZE = 0x80_00; + const arr = []; + for (let i = 0; i < input.length; i += CHUNK_SIZE) { + arr.push(String.fromCodePoint(...input.subarray(i, i + CHUNK_SIZE))); + } + return btoa(arr.join('')); + }; + +export const parseUint8Array = (json: string): Uint8Array => { + const parsed = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null || !('$binary' in parsed) || typeof parsed.$binary !== 'string') { + throw new TypeError('Invalid JSON format, expected {"$binary":"..."}'); + } + const binaryString = fromBase64(parsed.$binary); + return new Uint8Array(binaryString); +}; + +export const stringifyUint8Array = (data: Uint8Array): string => `{"$binary":"${toBase64(data)}"}`; diff --git a/packages/e2ee/src/codec.ts b/packages/e2ee/src/codec.ts deleted file mode 100644 index 66a61cd7f4c84..0000000000000 --- a/packages/e2ee/src/codec.ts +++ /dev/null @@ -1,239 +0,0 @@ -// oxlint-disable sort-keys -// oxlint-disable explicit-function-return-type -import type { CryptoProvider } from './crypto.ts'; - -export interface JsonWebKeyPair { - privateJWK: JsonWebKey; - publicJWK: JsonWebKey; -} - -const V1_ITERATIONS = 1000; // mirrors legacy implementation -export interface EncodedPrivateKeyV1 { - version: '1'; - mode: 'AES-CBC'; - kdf: { - name: 'PBKDF2'; - hash: 'SHA-256'; - iterations: typeof V1_ITERATIONS; - }; - ivB64: string; - /** ciphertext */ - ctB64: string; -} - -export type AnyEncodedPrivateKey = EncodedPrivateKeyV1; // forward compatible discriminated union - -const provider = { - exportJsonWebKey: (key) => crypto.subtle.exportKey('jwk', key), - getRandomUint8Array: (array) => crypto.getRandomValues(array), - getRandomUint16Array: (array) => crypto.getRandomValues(array), - getRandomUint32Array: (array) => crypto.getRandomValues(array), - importRawKey: (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']), - importRsaDecryptKey: (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']), - generateRsaOaepKeyPair: () => - crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ - 'encrypt', - 'decrypt', - ]), - - deriveKeyWithPbkdf2: (salt, baseKey) => - crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt, - iterations: 1000, - hash: 'SHA-256', - }, - baseKey, - { - name: 'AES-CBC', - length: 256, - }, - false, - ['encrypt', 'decrypt'], - ), - decryptAesCbc: (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data), - encryptAesCbc: (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data), - decodeBase64: (input) => { - const binaryString = atob(input); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; - }, - encodeBase64: (input) => { - const bytes = new Uint8Array(input); - let binaryString = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binaryString += String.fromCharCode(bytes[i] ?? 0); - } - return btoa(binaryString); - }, - encodeBinary: (input) => { - const encoder = new TextEncoder(); - const dest = new Uint8Array(input.length); - encoder.encodeInto(input, dest); - return dest; - }, - encodeUtf8: (input) => new TextEncoder().encode(input), - decodeUtf8: (input) => new TextDecoder().decode(input), -} satisfies CryptoProvider; -export default class KeyCodec { - #crypto: CryptoProvider; - - get crypto(): CryptoProvider { - return this.#crypto; - } - - constructor() { - this.#crypto = provider; - } - - async generateRsaOaepKeyPair(): Promise { - const { generateRsaOaepKeyPair, exportJsonWebKey } = this.#crypto; - - const keyPair = await generateRsaOaepKeyPair(); - const publicJWK = await exportJsonWebKey(keyPair.publicKey); - const privateJWK = await exportJsonWebKey(keyPair.privateKey); - return { privateJWK, publicJWK }; - } - - getRandomUint8Array(length: number): Uint8Array { - return this.#crypto.getRandomUint8Array(new Uint8Array(length)); - } - - getRandomUint32Array(length: number): Uint32Array { - return this.#crypto.getRandomUint32Array(new Uint32Array(length)); - } - - async encodePrivateKey(privateKeyJwk: JsonWebKey, masterKey: CryptoKey): Promise { - const { encodeBase64, encryptAesCbc, encodeUtf8 } = this.#crypto; - const IV_LENGTH = 16; - const iv = this.getRandomUint8Array(IV_LENGTH); - const data = encodeUtf8(JSON.stringify(privateKeyJwk)); - const ct = await encryptAesCbc(masterKey, iv, data); - - return { - ctB64: encodeBase64(ct), - ivB64: encodeBase64(iv.buffer), - kdf: { hash: 'SHA-256', iterations: V1_ITERATIONS, name: 'PBKDF2' }, - mode: 'AES-CBC', - version: '1', - }; - } - - async decodePrivateKey(encoded: AnyEncodedPrivateKey, masterKey: CryptoKey): Promise { - if (encoded.version !== '1') { - throw new Error(`Unsupported encoded private key version: ${encoded.version}`); - } - if (encoded.mode !== 'AES-CBC') { - throw new Error(`Unsupported cipher mode: ${encoded.mode}`); - } - const iv = this.#crypto.decodeBase64(encoded.ivB64); - const ct = this.#crypto.decodeBase64(encoded.ctB64); - try { - const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return JSON.parse(this.#crypto.decodeUtf8(plain)) as JsonWebKey; - } catch { - throw new Error('Failed to decrypt private key'); - } - } - - encodeSalt(token: string): Uint8Array { - return this.#crypto.encodeUtf8(token); - } - - decodeSalt(salt: Uint8Array): string { - return this.#crypto.decodeUtf8(salt.buffer); - } - - createBaseKey(password: string): Promise { - if (!password) { - throw new Error('Password is required'); - } - const { encodeBinary, importRawKey } = this.#crypto; - return importRawKey(encodeBinary(password)); - } - - deriveKey(salt: string, baseKey: CryptoKey): Promise { - return this.#crypto.deriveKeyWithPbkdf2(this.#crypto.encodeUtf8(salt).buffer, baseKey); - } - - async deriveMasterKey(salt: Uint8Array, password: string): Promise { - if (!password) { - throw new Error('Password is required'); - } - const { encodeUtf8, deriveKeyWithPbkdf2, importRawKey } = this.#crypto; - - try { - const baseKey = await importRawKey(encodeUtf8(password)); - return deriveKeyWithPbkdf2(salt.buffer, baseKey); - } catch (error) { - if (error instanceof Error) { - throw new TypeError(`Invalid password: ${error.message}`); - } else { - throw error; - } - } - } - - // Legacy helpers replicate existing Rocket.Chat behavior (vector + ciphertext joined) for staged migration. - async legacyEncrypt(privateKeyJwkString: string, password: string, saltStr: string): Promise> { - const { encodeUtf8, encryptAesCbc } = this.#crypto; - const IV_LENGTH = 16; - const salt = encodeUtf8(saltStr); - const masterKey = await this.deriveMasterKey(salt, password); - const iv = this.getRandomUint8Array(IV_LENGTH); - const data = encodeUtf8(privateKeyJwkString); - const ct = await encryptAesCbc(masterKey, iv, data); - const ctBytes = new Uint8Array(ct); - const out = new Uint8Array(iv.length + ctBytes.length); - out.set(iv); - out.set(ctBytes, iv.length); - return out; - } - - async legacyDecrypt(bytes: Uint8Array, password: string, saltStr: string): Promise { - const LEGACY_IV_START = 0; - const LEGACY_IV_LENGTH = 16; - if (bytes.length <= LEGACY_IV_LENGTH) { - throw new TypeError('Invalid legacy encrypted blob'); - } - - const { encodeUtf8, decodeUtf8 } = this.#crypto; - - const iv = bytes.slice(LEGACY_IV_START, LEGACY_IV_LENGTH); - const ct = bytes.slice(LEGACY_IV_LENGTH); - const salt = encodeUtf8(saltStr); - const masterKey = await this.deriveMasterKey(salt, password); - try { - const plain = await this.#crypto.decryptAesCbc(masterKey, iv, ct); - return JSON.parse(decodeUtf8(plain)) as JsonWebKey; - } catch { - throw new Error('Failed to decrypt legacy private key'); - } - } - - async generateMnemonicPhrase(length: number): Promise { - const { v1 } = await import('./word-list.ts'); - const randomBuffer = this.getRandomUint32Array(length); - return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); - } - - stringifyUint8Array(data: Uint8Array): string { - return JSON.stringify({ - $binary: this.#crypto.encodeBase64(data.buffer), - }); - } - - parseUint8Array(json: string): Uint8Array { - const parsed = JSON.parse(json); - if (typeof parsed !== 'object' || parsed === null || !('$binary' in parsed) || typeof parsed.$binary !== 'string') { - throw new TypeError('Invalid JSON format, expected {"$binary":"..."}'); - } - const binaryString = this.#crypto.decodeBase64(parsed.$binary); - return new Uint8Array(binaryString); - } -} diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/crypto.ts index 303333786e6f8..67b743e64726e 100644 --- a/packages/e2ee/src/crypto.ts +++ b/packages/e2ee/src/crypto.ts @@ -1,131 +1,8 @@ -export interface CryptoProvider { - /** - * @example - * (key) => await crypto.subtle.exportKey('jwk', key) - */ - exportJsonWebKey(key: CryptoKey): Promise; - /** - * @example - * (raw) => crypto.subtle.importKey('raw', raw, { name: 'PBKDF2' }, false, ['deriveKey']) - */ - importRawKey(raw: Uint8Array): Promise; - /** - * @example - * (key) => crypto.subtle.importKey('jwk', key, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']) - */ - importRsaDecryptKey(key: JsonWebKey): Promise; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint8Array(array: Uint8Array): Uint8Array; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint16Array(array: Uint16Array): Uint16Array; - /** - * @example - * (array) => crypto.getRandomValues(array), - */ - getRandomUint32Array(array: Uint32Array): Uint32Array; - /** - * @example - * () => crypto.subtle.generateKey( - * { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - * true, - * ['encrypt', 'decrypt'], - * ) - */ - generateRsaOaepKeyPair(): Promise; - /** - * @example - * (input) => { - * const binaryString = atob(input); - * const len = binaryString.length; - * const bytes = new Uint8Array(len); - * for (let i = 0; i < len; i++) { - * bytes[i] = binaryString.charCodeAt(i); - * } - * return bytes; - * } - */ - decodeBase64(input: string): Uint8Array; - /** - * @example - * (input) => { - * const bytes = new Uint8Array(input); - * let binaryString = ''; - * for (let i = 0; i < bytes.byteLength; i++) { - * binaryString += String.fromCharCode(bytes[i]!); - * } - * return btoa(binaryString); - * } - */ - encodeBase64(input: ArrayBuffer): string; - /** - * @example - * NodeJS / Web: - * ``` - * (text: string): Uint8Array => { - * const encoder = new TextEncoder(); - * const buffer = new Uint8Array(); - * encoder.encodeInto(text, buffer); - * return buffer; - * } - * ``` - */ - encodeBinary(input: string): Uint8Array; - /** - * @example - * NodeJS / Web: - * ``` - * (input) => new TextEncoder().encode(input) - * ``` - */ - encodeUtf8(input: string): Uint8Array; - /** - * @example - * NodeJS / Web: - * ``` - * (input) => TextDecoder().decode(input) - * ``` - */ - decodeUtf8(input: ArrayBuffer): string; - /** - * @example - * NodeJS / Web: - * ``` - * (key, iv, data) => crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data) - * ``` - */ - decryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; - /** - * @example - * NodeJS / Web: - * ``` - * (key, iv, data) => crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data) - * ```` - */ - encryptAesCbc(key: CryptoKey, iv: Uint8Array, data: Uint8Array): Promise; - /** - * @example - * NodeJS / Web: - * (key, iv, data) => crypto.subtle.deriveKey( - * { - * name: 'PBKDF2', - * salt, - * iterations: 1000, - * hash: 'SHA-256', - * }, - * baseKey, - * { - * name: 'AES-CBC', - * length: 256, - * }, - * false, - * ['encrypt', 'decrypt'] - * ) - */ - deriveKeyWithPbkdf2(salt: ArrayBuffer, baseKey: CryptoKey): Promise; -} +export const generateRsaOaepKeyPair = async (): Promise => { + const keys = await crypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ); + return keys; +}; diff --git a/packages/e2ee/src/derive.ts b/packages/e2ee/src/derive.ts new file mode 100644 index 0000000000000..af95f80740a23 --- /dev/null +++ b/packages/e2ee/src/derive.ts @@ -0,0 +1,28 @@ +export const deriveKeyWithPbkdf2 = (salt: string, baseKey: CryptoKey): Promise => + crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new TextEncoder().encode(salt), + iterations: 1000, + hash: 'SHA-256', + }, + baseKey, + { + name: 'AES-CBC', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); + +export const importPbkdf2Key = (password: string): Promise => { + if (!password) { + throw new Error('Password is required'); + } + + const encoder = new TextEncoder(); + const dest = new Uint8Array(password.length); + encoder.encodeInto(password, dest); + + return crypto.subtle.importKey('raw', dest, { name: 'PBKDF2' }, false, ['deriveKey']); +}; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index f26c511b58bd1..dce5cd7e61a8d 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -1,15 +1,24 @@ import { type AsyncResult, err, ok } from './result.ts'; import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; import { toArrayBuffer, toString } from './binary.ts'; -import KeyCodec from './codec.ts'; export * as Jwk from './jwk.ts'; import * as Jwk from './jwk.ts'; +import { stringifyUint8Array, parseUint8Array } from './base64.ts'; +import { importPbkdf2Key, deriveKeyWithPbkdf2 } from './derive.ts'; +import { generateRsaOaepKeyPair } from './crypto.ts'; + +const generateMnemonicPhrase = async (length: number): Promise => { + const { v1 } = await import('./word-list.ts'); + const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); +}; + export interface KeyService { userId: () => Promise; fetchMyKeys: () => Promise; - persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; + persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; } export type PrivateKey = string; @@ -32,11 +41,9 @@ export interface KeyPair { export default class E2EE { #service: KeyService; - #codec: KeyCodec; constructor(service: KeyService) { this.#service = service; - this.#codec = new KeyCodec(); } async loadKeys({ public_key, private_key }: RemoteKeyPair): AsyncResult { @@ -54,7 +61,7 @@ export default class E2EE { async createAndLoadKeys(): AsyncResult { try { - const keys = await this.#codec.crypto.generateRsaOaepKeyPair(); + const keys = await generateRsaOaepKeyPair(); try { const publicKey = await this.setPublicKey(keys.publicKey); try { @@ -109,11 +116,13 @@ export default class E2EE { return masterKey; } const IV_LENGTH = 16; - const vector = this.#codec.getRandomUint8Array(IV_LENGTH); + const vector = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); try { - const encodedPrivateKey = await this.#codec.crypto.encryptAesCbc(masterKey.value, vector, toArrayBuffer(privateKey)); - - return ok(this.#codec.stringifyUint8Array(joinVectorAndEncryptedData(vector, encodedPrivateKey))); + const privateKeyBuffer = toArrayBuffer(privateKey); + const encryptedPrivateKey = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, masterKey.value, privateKeyBuffer); + const joined = joinVectorAndEncryptedData(vector, encryptedPrivateKey); + const stringified = stringifyUint8Array(joined); + return ok(stringified); } catch (error) { return err(new Error('Error encrypting encodedPrivateKey', { cause: error })); } @@ -125,10 +134,10 @@ export default class E2EE { return masterKey; } - const [vector, cipherText] = splitVectorAndEncryptedData(this.#codec.parseUint8Array(privateKey)); + const [vector, cipherText] = splitVectorAndEncryptedData(parseUint8Array(privateKey)); try { - const privKey = await this.#codec.crypto.decryptAesCbc(masterKey.value, vector, cipherText); + const privKey = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, masterKey.value, cipherText); return ok(toString(privKey)); } catch (error) { return err(new Error('Error decrypting private key', { cause: error })); @@ -142,14 +151,14 @@ export default class E2EE { * @param key The public key to store. */ async setPublicKey(key: CryptoKey): Promise { - const exported = await this.#codec.crypto.exportJsonWebKey(key); + const exported = await crypto.subtle.exportKey('jwk', key); const stringified = JSON.stringify(exported); localStorage.setItem('public_key', stringified); return exported; } async setPrivateKey(key: CryptoKey): Promise { - const exported = await this.#codec.crypto.exportJsonWebKey(key); + const exported = await crypto.subtle.exportKey('jwk', key); const stringified = JSON.stringify(exported); localStorage.setItem('private_key', stringified); } @@ -162,7 +171,7 @@ export default class E2EE { // First, create a PBKDF2 "key" containing the password const baseKey = await (async () => { try { - return ok(await this.#codec.createBaseKey(password)); + return ok(await importPbkdf2Key(password)); } catch (error) { return err(new Error(`Error creating a key based on user password: ${error}`)); } @@ -180,7 +189,7 @@ export default class E2EE { // Derive a key from the password try { - return ok(await this.#codec.deriveKey(userId, baseKey.value)); + return ok(await deriveKeyWithPbkdf2(userId, baseKey.value)); } catch (error) { return err(new Error(`Error deriving baseKey: ${error}`)); } @@ -222,8 +231,8 @@ export default class E2EE { return localStorage.removeItem('e2e.random_password'); } async createRandomPassword(length: number): Promise { - const randomPassword = await this.#codec.generateMnemonicPhrase(length); - await this.storeRandomPassword(randomPassword); + const randomPassword = await generateMnemonicPhrase(length); + this.storeRandomPassword(randomPassword); return randomPassword; } } diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts index f846a7310ceef..a960ac1ddfab5 100644 --- a/packages/e2ee/src/jwk.ts +++ b/packages/e2ee/src/jwk.ts @@ -55,9 +55,12 @@ const isKey = (jwk): jwk is Jwk => jwk.kty === kty && jwk.alg === alg; +/** {@link !CryptoKey} */ export const isAesGcm = (jwk: Partial): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); export const isAesCbc = (jwk: Partial): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); +export const isRsaOaep = (jwk: Partial): jwk is Jwk<'RSA', 'RSA-OAEP-256'> => isKey('RSA', 'RSA-OAEP-256')(jwk); + export const parse = (json: string): Jwk => { const obj = JSON.parse(json); if (!is(obj)) { diff --git a/packages/e2ee/tsconfig.build.json b/packages/e2ee/tsconfig.build.json index cf4278ae09d96..cf0eba4741f1a 100644 --- a/packages/e2ee/tsconfig.build.json +++ b/packages/e2ee/tsconfig.build.json @@ -3,6 +3,5 @@ "compilerOptions": { "noEmit": false }, - "include": ["src"], "exclude": ["src/**/*.test.ts"] } diff --git a/packages/e2ee/turbo.json b/packages/e2ee/turbo.json new file mode 100644 index 0000000000000..45753e0a3055f --- /dev/null +++ b/packages/e2ee/turbo.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "tasks": { + "testunit": { + "dependsOn": [], + "outputs": ["coverage/**"] + } + } +} \ No newline at end of file diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index 71b9de22983f5..22852e08f77bf 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from 'vitest/config'; - export default defineConfig({ test: { browser: { diff --git a/yarn.lock b/yarn.lock index cda65c0d2d27a..716e389514b3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4758,100 +4758,100 @@ __metadata: languageName: node linkType: hard -"@oxlint-tsgolint/darwin-arm64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.1.2" +"@oxlint-tsgolint/darwin-arm64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.1.5" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/darwin-x64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.1.2" +"@oxlint-tsgolint/darwin-x64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.1.5" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-arm64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.1.2" +"@oxlint-tsgolint/linux-arm64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.1.5" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/linux-x64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.1.2" +"@oxlint-tsgolint/linux-x64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.1.5" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-arm64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.1.2" +"@oxlint-tsgolint/win32-arm64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.1.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint-tsgolint/win32-x64@npm:0.1.2": - version: 0.1.2 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.1.2" +"@oxlint-tsgolint/win32-x64@npm:0.1.5": + version: 0.1.5 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.1.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@oxlint/darwin-arm64@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/darwin-arm64@npm:1.13.0" +"@oxlint/darwin-arm64@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/darwin-arm64@npm:1.14.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/darwin-x64@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/darwin-x64@npm:1.13.0" +"@oxlint/darwin-x64@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/darwin-x64@npm:1.14.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/linux-arm64-gnu@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/linux-arm64-gnu@npm:1.13.0" +"@oxlint/linux-arm64-gnu@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.14.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-arm64-musl@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/linux-arm64-musl@npm:1.13.0" +"@oxlint/linux-arm64-musl@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.14.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/linux-x64-gnu@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/linux-x64-gnu@npm:1.13.0" +"@oxlint/linux-x64-gnu@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.14.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-x64-musl@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/linux-x64-musl@npm:1.13.0" +"@oxlint/linux-x64-musl@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/linux-x64-musl@npm:1.14.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxlint/win32-arm64@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/win32-arm64@npm:1.13.0" +"@oxlint/win32-arm64@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/win32-arm64@npm:1.14.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/win32-x64@npm:1.13.0": - version: 1.13.0 - resolution: "@oxlint/win32-x64@npm:1.13.0" +"@oxlint/win32-x64@npm:1.14.0": + version: 1.14.0 + resolution: "@oxlint/win32-x64@npm:1.14.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -7359,12 +7359,12 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: - "@vitest/browser": "npm:4.0.0-beta.8" - oxlint: "npm:~1.13.0" - oxlint-tsgolint: "npm:~0.1.2" - playwright: "npm:~1.55.0" + "@vitest/browser": "npm:4.0.0-beta.9" + oxlint: "npm:~1.14.0" + oxlint-tsgolint: "npm:~0.1.5" + playwright: "npm:^1.55.0" typescript: "npm:~5.9.2" - vitest: "npm:4.0.0-beta.8" + vitest: "npm:4.0.0-beta.9" languageName: unknown linkType: soft @@ -13365,14 +13365,14 @@ __metadata: languageName: node linkType: hard -"@vitest/browser@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/browser@npm:4.0.0-beta.8" +"@vitest/browser@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/browser@npm:4.0.0-beta.9" dependencies: "@testing-library/dom": "npm:^10.4.1" "@testing-library/user-event": "npm:^14.6.1" - "@vitest/mocker": "npm:4.0.0-beta.8" - "@vitest/utils": "npm:4.0.0-beta.8" + "@vitest/mocker": "npm:4.0.0-beta.9" + "@vitest/utils": "npm:4.0.0-beta.9" magic-string: "npm:^0.30.17" pixelmatch: "npm:7.1.0" pngjs: "npm:^7.0.0" @@ -13381,7 +13381,7 @@ __metadata: ws: "npm:^8.18.3" peerDependencies: playwright: "*" - vitest: 4.0.0-beta.8 + vitest: 4.0.0-beta.9 webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 peerDependenciesMeta: playwright: @@ -13390,7 +13390,7 @@ __metadata: optional: true webdriverio: optional: true - checksum: 10/8e8897d4fdc9f3436e7f6c7b72ca02d71bb95ef4fed6dd6f7464c9ece2cc077ffed79f6e0c6d072a399fd5238ade97f01822c19fd00bc58e7aecc2dc3b8f5b01 + checksum: 10/5316cf83452995efa9c9daadc5d5884f0fbe04b9330e624e903ab705a22514a42637cbcbf7f2ea1a9b10306ca80c430856f1cc5cc770bff25b74a7391bd53a5e languageName: node linkType: hard @@ -13406,24 +13406,24 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/expect@npm:4.0.0-beta.8" +"@vitest/expect@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/expect@npm:4.0.0-beta.9" dependencies: "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.0-beta.8" - "@vitest/utils": "npm:4.0.0-beta.8" - chai: "npm:^5.2.1" + "@vitest/spy": "npm:4.0.0-beta.9" + "@vitest/utils": "npm:4.0.0-beta.9" + chai: "npm:^6.0.1" tinyrainbow: "npm:^2.0.0" - checksum: 10/18380a9a3f328d7832db84803fe45c7fb668b190522d0763adfebefb10e9af00c5f33e06535574915a9deda5fb32960f60483e49137e36b9b585723e02cd8dff + checksum: 10/d2964ec77aedb8bc437f5563d3ce88078552930a5e41a6853ecd3d73f87258d9bccd095a176bed4807dab8aa937d9706ae972211741f7d0045767bfec52e07c6 languageName: node linkType: hard -"@vitest/mocker@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/mocker@npm:4.0.0-beta.8" +"@vitest/mocker@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/mocker@npm:4.0.0-beta.9" dependencies: - "@vitest/spy": "npm:4.0.0-beta.8" + "@vitest/spy": "npm:4.0.0-beta.9" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.17" peerDependencies: @@ -13434,7 +13434,7 @@ __metadata: optional: true vite: optional: true - checksum: 10/3942f71062bf7c5ae6cb7c04888d84eb88a49280aa7c002304f60c726f081da24059cf8beb5e717e838379008d9bc88aee2d30623d96e7e8f82e26ff219ff555 + checksum: 10/4f5cc60cd5e41e053bcdae5f62847d1a56e79756cfa40629b37ed871e8c5b89b1e79a5c2f80ae0ada8c83a077f3542b50cf8f45fb3478383c49cfa5d7a0c8df9 languageName: node linkType: hard @@ -13456,34 +13456,34 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.0-beta.8, @vitest/pretty-format@npm:^4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/pretty-format@npm:4.0.0-beta.8" +"@vitest/pretty-format@npm:4.0.0-beta.9, @vitest/pretty-format@npm:^4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/pretty-format@npm:4.0.0-beta.9" dependencies: tinyrainbow: "npm:^2.0.0" - checksum: 10/f13716c42c3dcadcb395a2b3f38e9ca28192e22dde024be833911088acfa6e29dd13bc6d1e0768a6a8181e6861a5296b8f24a7b8af56e3fab8c353194ac32ce7 + checksum: 10/8a7fc725aa8f0ad7556819955611e5877a0208cfcf053cfa6d319bbe26761e62f20ef29e9dbb028db71cf4082a338ff430274709582ad51a6fa47a36b5db2a8d languageName: node linkType: hard -"@vitest/runner@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/runner@npm:4.0.0-beta.8" +"@vitest/runner@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/runner@npm:4.0.0-beta.9" dependencies: - "@vitest/utils": "npm:4.0.0-beta.8" + "@vitest/utils": "npm:4.0.0-beta.9" pathe: "npm:^2.0.3" strip-literal: "npm:^3.0.0" - checksum: 10/6f6b24e3aa8866c506b1cc956936a9feba2e9e88465c5dd18c00709a1839c4ee4570b38ed27c299047c03dcc32de1b91a71ef0d5d95cab9d992052e32a455e2b + checksum: 10/d7b3b4e3e34dac248d454fc0c26cdc5e86b32f971421aca67aad31ee9269e324ae5b09153675059ee3037cb5f36956e0a86ab614dfaacc469540c2061221f2a6 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/snapshot@npm:4.0.0-beta.8" +"@vitest/snapshot@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/snapshot@npm:4.0.0-beta.9" dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.8" + "@vitest/pretty-format": "npm:4.0.0-beta.9" magic-string: "npm:^0.30.17" pathe: "npm:^2.0.3" - checksum: 10/d1d6d3aa9939c8a7abd07ef88f648cff762b71f981b8cd6625759105c992b0b400c168ec49aa77992a83da3263795355dbcc967d14108acf41513ccb78089ea9 + checksum: 10/6b02b2aac1c223ffbdb5716486aec864387a41b7dccbb9acdf92644da091a2da25de84f1c1a8f5c4cf0034d7f6a52dcf432caba0f10db692bf5cf939a8291758 languageName: node linkType: hard @@ -13496,10 +13496,10 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/spy@npm:4.0.0-beta.8" - checksum: 10/71a0d4addbb100e4342aa0fc15b297bb1e04536543d2ce13442da328a94b5eb37b606af53adee06cee795c03faa21b84c3919ac2b0377d198c44f9025b193c5e +"@vitest/spy@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/spy@npm:4.0.0-beta.9" + checksum: 10/45d7b574d99926989cf4f0ce4245e57eb2f3d093df5b5397b6391aea7d0f71b5045fc4cb0807be4a9dff998e01b9e9ec9e852be014c0f14879e30fc013d1321f languageName: node linkType: hard @@ -13515,14 +13515,14 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "@vitest/utils@npm:4.0.0-beta.8" +"@vitest/utils@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/utils@npm:4.0.0-beta.9" dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.8" + "@vitest/pretty-format": "npm:4.0.0-beta.9" loupe: "npm:^3.2.0" tinyrainbow: "npm:^2.0.0" - checksum: 10/1a9ee2bce6e6ca1b62c7fa96cb79d0511090ddf1e6dd8743d211f060bae92e2e69bb713508e447f6f9bf23f3f3acc9a06b775677d8b433af1af47a88e960802f + checksum: 10/b6b0d731ffb0bb803857eff1c3b93a638f3ea064b728ca15d17cbe7afbf36c551e0f41d77a7ac405f33db1ab2c7c353180c26a7aed8a7bc17467668c9d686f42 languageName: node linkType: hard @@ -16178,16 +16178,10 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.2.1": - version: 5.2.1 - resolution: "chai@npm:5.2.1" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10/2d9b14c9bbb9b791641ecee0d74a53f34d2c86f05c10b5567041ed376177f87af9dc7f5398207fd2711affbfeb9f0f1f60e11bcaf021f78ef37b7c65a6d94e7d +"chai@npm:^6.0.1": + version: 6.0.1 + resolution: "chai@npm:6.0.1" + checksum: 10/cd95aee5f98cdeaea3e24c21dcf1786d9eaf515d571861106a2378589cf5ef1ed3ddd223ca1121552656ffd744e4acd051f8881c1a9729c71551776db55fb2cb languageName: node linkType: hard @@ -28502,16 +28496,16 @@ __metadata: languageName: node linkType: hard -"oxlint-tsgolint@npm:~0.1.2": - version: 0.1.2 - resolution: "oxlint-tsgolint@npm:0.1.2" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.1.2" - "@oxlint-tsgolint/darwin-x64": "npm:0.1.2" - "@oxlint-tsgolint/linux-arm64": "npm:0.1.2" - "@oxlint-tsgolint/linux-x64": "npm:0.1.2" - "@oxlint-tsgolint/win32-arm64": "npm:0.1.2" - "@oxlint-tsgolint/win32-x64": "npm:0.1.2" +"oxlint-tsgolint@npm:~0.1.5": + version: 0.1.5 + resolution: "oxlint-tsgolint@npm:0.1.5" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.1.5" + "@oxlint-tsgolint/darwin-x64": "npm:0.1.5" + "@oxlint-tsgolint/linux-arm64": "npm:0.1.5" + "@oxlint-tsgolint/linux-x64": "npm:0.1.5" + "@oxlint-tsgolint/win32-arm64": "npm:0.1.5" + "@oxlint-tsgolint/win32-x64": "npm:0.1.5" dependenciesMeta: "@oxlint-tsgolint/darwin-arm64": optional: true @@ -28527,24 +28521,24 @@ __metadata: optional: true bin: tsgolint: bin/tsgolint.js - checksum: 10/5815575524c59227ba11cbce2a3f4b474a98c74cc1bd5066bb9e208f36f91bd283a2840712cdbdaaf12034bc4e9386665c4937896a7695c739eb05f769bf113b + checksum: 10/163ce643bc4a33238fae0999a150b30c6e7548cfc44f30a045581c0fb007c5a17f5fe834c425d06047fe117c66a9b0f94cdbd2919c4e5dca4d481e7586497c71 languageName: node linkType: hard -"oxlint@npm:~1.13.0": - version: 1.13.0 - resolution: "oxlint@npm:1.13.0" - dependencies: - "@oxlint/darwin-arm64": "npm:1.13.0" - "@oxlint/darwin-x64": "npm:1.13.0" - "@oxlint/linux-arm64-gnu": "npm:1.13.0" - "@oxlint/linux-arm64-musl": "npm:1.13.0" - "@oxlint/linux-x64-gnu": "npm:1.13.0" - "@oxlint/linux-x64-musl": "npm:1.13.0" - "@oxlint/win32-arm64": "npm:1.13.0" - "@oxlint/win32-x64": "npm:1.13.0" - peerDependencies: - oxlint-tsgolint: ">=0.0.4" +"oxlint@npm:~1.14.0": + version: 1.14.0 + resolution: "oxlint@npm:1.14.0" + dependencies: + "@oxlint/darwin-arm64": "npm:1.14.0" + "@oxlint/darwin-x64": "npm:1.14.0" + "@oxlint/linux-arm64-gnu": "npm:1.14.0" + "@oxlint/linux-arm64-musl": "npm:1.14.0" + "@oxlint/linux-x64-gnu": "npm:1.14.0" + "@oxlint/linux-x64-musl": "npm:1.14.0" + "@oxlint/win32-arm64": "npm:1.14.0" + "@oxlint/win32-x64": "npm:1.14.0" + peerDependencies: + oxlint-tsgolint: ">=0.1.5" dependenciesMeta: "@oxlint/darwin-arm64": optional: true @@ -28568,7 +28562,7 @@ __metadata: bin: oxc_language_server: bin/oxc_language_server oxlint: bin/oxlint - checksum: 10/af576f35f76a29c368cb4a199d694bf5fd10adaf0f7c1b624db23889f5ebadeea0d9081a298f1ee7be92eea79fa96f07e390133b8813b4af420a0642533e11b6 + checksum: 10/b5b17f85250626a5b2993e11b4c1a22c55f07765eb4e6b12b5977ec759c7586fd81d9b71cc23f473fe385928dc02e3c7d982d0bd4e26e2b420f94a9e8618a32c languageName: node linkType: hard @@ -29525,7 +29519,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.55.0, playwright@npm:~1.55.0": +"playwright@npm:1.55.0, playwright@npm:^1.55.0": version: 1.55.0 resolution: "playwright@npm:1.55.0" dependencies: @@ -36695,19 +36689,17 @@ __metadata: languageName: node linkType: hard -"vitest@npm:4.0.0-beta.8": - version: 4.0.0-beta.8 - resolution: "vitest@npm:4.0.0-beta.8" +"vitest@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "vitest@npm:4.0.0-beta.9" dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:4.0.0-beta.8" - "@vitest/mocker": "npm:4.0.0-beta.8" - "@vitest/pretty-format": "npm:^4.0.0-beta.8" - "@vitest/runner": "npm:4.0.0-beta.8" - "@vitest/snapshot": "npm:4.0.0-beta.8" - "@vitest/spy": "npm:4.0.0-beta.8" - "@vitest/utils": "npm:4.0.0-beta.8" - chai: "npm:^5.2.1" + "@vitest/expect": "npm:4.0.0-beta.9" + "@vitest/mocker": "npm:4.0.0-beta.9" + "@vitest/pretty-format": "npm:^4.0.0-beta.9" + "@vitest/runner": "npm:4.0.0-beta.9" + "@vitest/snapshot": "npm:4.0.0-beta.9" + "@vitest/spy": "npm:4.0.0-beta.9" + "@vitest/utils": "npm:4.0.0-beta.9" debug: "npm:^4.4.1" es-module-lexer: "npm:^1.7.0" expect-type: "npm:^1.2.2" @@ -36726,8 +36718,8 @@ __metadata: "@edge-runtime/vm": "*" "@types/debug": ^4.1.12 "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 4.0.0-beta.8 - "@vitest/ui": 4.0.0-beta.8 + "@vitest/browser": 4.0.0-beta.9 + "@vitest/ui": 4.0.0-beta.9 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -36747,7 +36739,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10/04ea772066e157d79a4202d8b360483f7c2114bcd5ea9920322f043fc0ca14c482031d84476d922c772397da2a1bc824f12a5002bd590f1f50eb30ff7da03440 + checksum: 10/a42aca09a8d141e1728a1b55f43fcf7daf2f68f70c8d42c02d3c4017971a28cad722698bc98f53a297aa8ef815ddbf68d35af125fdb417a79934e3faacdb20a3 languageName: node linkType: hard From f9182a8df125ae4dddfba244b6f817a755600fce Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 30 Aug 2025 13:07:30 -0300 Subject: [PATCH 070/251] improve tests --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 10 ++-- packages/e2ee/package.json | 3 +- packages/e2ee/src/__tests__/base64.test.ts | 12 +++-- packages/e2ee/src/__tests__/e2ee.test.ts | 53 +++++++++++++------ packages/e2ee/src/__tests__/helpers/db.ts | 45 ++++++++++++++++ packages/e2ee/src/index.ts | 16 ++---- packages/e2ee/tsconfig.build.json | 2 +- packages/e2ee/vitest.config.ts | 8 +-- 8 files changed, 106 insertions(+), 43 deletions(-) create mode 100644 packages/e2ee/src/__tests__/helpers/db.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 78e57de14fddb..8624a9bd1aede 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -71,12 +71,10 @@ class E2E extends Emitter<{ userId: () => Promise.resolve(Meteor.userId()), fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), persistKeys: (keys, force) => - sdk.rest - .post('/v1/e2e.setUserPublicAndPrivateKeys', { - ...keys, - force, - }) - .then((response) => response ?? undefined), + sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { + ...keys, + force, + }), }); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index cdf9234cf5c86..7ac184049635a 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -20,7 +20,8 @@ "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "oxlint --type-aware", - "testunit": "vitest run" + "testunit": "vitest run", + "test": "vitest" }, "devDependencies": { "@vitest/browser": "4.0.0-beta.9", diff --git a/packages/e2ee/src/__tests__/base64.test.ts b/packages/e2ee/src/__tests__/base64.test.ts index 6fc10e475c950..f77d2ea33e8a1 100644 --- a/packages/e2ee/src/__tests__/base64.test.ts +++ b/packages/e2ee/src/__tests__/base64.test.ts @@ -2,11 +2,15 @@ import { expect, test } from 'vitest'; import { parseUint8Array, stringifyUint8Array, toBase64, fromBase64 } from '../base64.ts'; const cases = [ - ['', new Uint8Array([])], - ['AQID', new Uint8Array([1, 2, 3])], -] satisfies [string: string, uint8array: Uint8Array][]; + ['', []], + ['AQID', [1, 2, 3]], + ['AAECAwQFBgcICQ==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], + ['AAECAwQFBgcICQoLDA0ODw==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], + ['AQIDBAUGBwgJCgsMDQ4P', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], +] as const satisfies [string: string, bytes: readonly number[]][]; -test.for(cases)('fromBase64', ([string, uint8array]) => { +test.for(cases)('"%s" === [%s]', ([string, bytes]) => { + const uint8array = new Uint8Array(bytes); expect(fromBase64(string)).toEqual(uint8array); expect(toBase64(uint8array)).toBe(string); expect(stringifyUint8Array(uint8array)).toBe(`{"$binary":"${string}"}`); diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 614e6d5639d77..2b0f1209ee64e 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,21 +1,44 @@ import { expect, test } from 'vitest'; import E2EE from '../index.ts'; +import { createDb } from './helpers/db.ts'; -const e2ee = new E2EE({ - fetchMyKeys: () => - Promise.resolve({ - public_key: 'mocked_public_key', - private_key: 'mocked_private_key', - }), - userId: () => Promise.resolve('mocked_user_id'), - persistKeys: () => Promise.resolve(), +const db = createDb(); + +const bob = new E2EE({ + fetchMyKeys: async () => { + return await db.get('bob'); + }, + userId: async () => 'bob', + persistKeys: (keys, force) => { + return db.set('bob', keys, force); + }, }); -test('E2EE createRandomPassword deterministic generation with 5 words', async () => { - // Inject custom word list by temporarily defining dynamic import. - // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. - // So we skip testing exact phrase (depends on wordList) and just assert shape. - const pwd = await e2ee.createRandomPassword(5); - expect(pwd.split(' ').length).toBe(5); - expect(e2ee.getRandomPassword()).toBe(pwd); +test('load user keys', async () => { + const keys = await bob.createAndLoadKeys(); + expect(keys.isOk).toBe(true); + expect(keys).toHaveProperty('value.publicKey'); + expect(keys).toHaveProperty('value.privateKey'); + const password = await bob.createRandomPassword(5); + expect(password.split(' ').length).toBe(5); + const localKeys = bob.getKeysFromLocalStorage(); + expect(localKeys.private_key).toBeDefined(); + expect(localKeys.public_key).toBeDefined(); + const result = await bob.persistKeys(localKeys, password, true); + expect(result.isOk).toBe(true); + + // Recovery + const recoveredKeys = await bob.loadKeysFromDB(); + expect(recoveredKeys.isOk).toBe(true); + expect(recoveredKeys).toHaveProperty('value.public_key'); + expect(recoveredKeys).toHaveProperty('value.private_key'); }); + +// test('E2EE createRandomPassword deterministic generation with 5 words', async () => { +// // Inject custom word list by temporarily defining dynamic import. +// // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. +// // So we skip testing exact phrase (depends on wordList) and just assert shape. +// const pwd = await bob.createRandomPassword(5); +// expect(pwd.split(' ').length).toBe(5); +// expect(bob.getRandomPassword()).toBe(pwd); +// }); diff --git a/packages/e2ee/src/__tests__/helpers/db.ts b/packages/e2ee/src/__tests__/helpers/db.ts new file mode 100644 index 0000000000000..d11ad1d4b935e --- /dev/null +++ b/packages/e2ee/src/__tests__/helpers/db.ts @@ -0,0 +1,45 @@ +import type { RemoteKeyPair } from '../..'; + +class Db { + data: Map; + constructor() { + this.data = new Map(); + } + + async get(userId: string): Promise { + return await new Promise((resolve, reject) => { + const data = this.data.get(userId); + setTimeout(() => { + data ? resolve(data) : reject(new Error('User not found')); + }, 100); + }); + } + + async set(userId: string, data: Data, force: boolean): Promise { + return await new Promise((resolve, reject) => { + setTimeout(() => { + if (this.data.has(userId)) { + if (!force) { + return reject(new Error('User already exists')); + } else { + this.data.set(userId, data); + resolve(); + } + } else { + this.data.set(userId, data); + resolve(); + } + }, 100); + }); + } + + async update(userId: string, update: Partial): Promise { + const user = await this.get(userId); + if (!user) { + throw new Error('User not found'); + } + this.data.set(userId, { ...user, ...update }); + } +} + +export const createDb = (): Db => new Db(); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index dce5cd7e61a8d..8d9cf1ab55a86 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -18,7 +18,7 @@ const generateMnemonicPhrase = async (length: number): Promise => { export interface KeyService { userId: () => Promise; fetchMyKeys: () => Promise; - persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; + persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; } export type PrivateKey = string; @@ -83,13 +83,13 @@ export default class E2EE { async loadKeysFromDB(): AsyncResult { try { - return ok(await this.getKeysFromService()); + return ok(await this.#service.fetchMyKeys()); } catch (error) { return err(new Error('Error loading keys from service', { cause: error })); } } - async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { + async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { return err(new TypeError('Failed to persist keys as they are not strings.')); } @@ -209,16 +209,6 @@ export default class E2EE { localStorage.removeItem('private_key'); } - async getKeysFromService(): Promise { - const keys = await this.#service.fetchMyKeys(); - - if (!keys) { - throw new Error('Failed to retrieve keys from service'); - } - - return keys; - } - storeRandomPassword(randomPassword: string): void { return localStorage.setItem('e2e.random_password', randomPassword); } diff --git a/packages/e2ee/tsconfig.build.json b/packages/e2ee/tsconfig.build.json index cf0eba4741f1a..653800a4672dd 100644 --- a/packages/e2ee/tsconfig.build.json +++ b/packages/e2ee/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "noEmit": false }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/__tests__"] } diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index 22852e08f77bf..ae7113ebd8a89 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -3,12 +3,14 @@ export default defineConfig({ test: { browser: { fileParallelism: false, - screenshotFailures: false, enabled: true, provider: 'playwright', - headless: true, // https://vitest.dev/guide/browser/playwright - instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], + instances: [ + { browser: 'chromium', headless: true }, + { browser: 'firefox', headless: true }, + { browser: 'webkit', headless: true }, + ], }, }, }); From 99fab9a33ff9e42c0330a7a3a2f5c708abef4f13 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 30 Aug 2025 13:51:21 -0300 Subject: [PATCH 071/251] test pbkdf2 --- packages/e2ee/src/__tests__/e2ee.test.ts | 29 ++++++++++++------ packages/e2ee/src/crypto.ts | 13 +++----- packages/e2ee/src/derive.ts | 4 +-- packages/e2ee/src/index.ts | 39 ++++++++++++++---------- packages/e2ee/src/result.ts | 9 +++++- 5 files changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 2b0f1209ee64e..3c3233e35503a 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import E2EE from '../index.ts'; import { createDb } from './helpers/db.ts'; +import { unwrap } from '../result.ts'; const db = createDb(); @@ -15,23 +16,31 @@ const bob = new E2EE({ }); test('load user keys', async () => { - const keys = await bob.createAndLoadKeys(); - expect(keys.isOk).toBe(true); - expect(keys).toHaveProperty('value.publicKey'); - expect(keys).toHaveProperty('value.privateKey'); + const keys = unwrap(await bob.createAndLoadKeys()); + expect(keys).toHaveProperty('publicKey'); + expect(keys).toHaveProperty('privateKey'); const password = await bob.createRandomPassword(5); expect(password.split(' ').length).toBe(5); const localKeys = bob.getKeysFromLocalStorage(); expect(localKeys.private_key).toBeDefined(); expect(localKeys.public_key).toBeDefined(); - const result = await bob.persistKeys(localKeys, password, true); - expect(result.isOk).toBe(true); + unwrap(await bob.persistKeys(localKeys, password, true)); // Recovery - const recoveredKeys = await bob.loadKeysFromDB(); - expect(recoveredKeys.isOk).toBe(true); - expect(recoveredKeys).toHaveProperty('value.public_key'); - expect(recoveredKeys).toHaveProperty('value.private_key'); + const recoveredKeys = unwrap(await bob.loadKeysFromDB()); + expect(recoveredKeys).toHaveProperty('public_key'); + expect(recoveredKeys).toHaveProperty('private_key'); + + // Load keys + const decodedKey = unwrap(await bob.decodePrivateKey(recoveredKeys.private_key, password)); + const privateKey = unwrap( + await bob.loadKeys({ + private_key: decodedKey, + public_key: recoveredKeys.public_key, + }), + ); + + expect(privateKey).toBeInstanceOf(CryptoKey); }); // test('E2EE createRandomPassword deterministic generation with 5 words', async () => { diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/crypto.ts index 67b743e64726e..b28f9ebca8996 100644 --- a/packages/e2ee/src/crypto.ts +++ b/packages/e2ee/src/crypto.ts @@ -1,8 +1,5 @@ -export const generateRsaOaepKeyPair = async (): Promise => { - const keys = await crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt'], - ); - return keys; -}; +export const generateRsaOaepKeyPair = async (): Promise => + crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ + 'encrypt', + 'decrypt', + ]); diff --git a/packages/e2ee/src/derive.ts b/packages/e2ee/src/derive.ts index af95f80740a23..5afe94fb5c124 100644 --- a/packages/e2ee/src/derive.ts +++ b/packages/e2ee/src/derive.ts @@ -1,9 +1,9 @@ -export const deriveKeyWithPbkdf2 = (salt: string, baseKey: CryptoKey): Promise => +export const derivePbkdf2Key = (salt: string, baseKey: CryptoKey): Promise => crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new TextEncoder().encode(salt), - iterations: 1000, + iterations: 10_000, hash: 'SHA-256', }, baseKey, diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 8d9cf1ab55a86..e4d2aa1d8e5bd 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -6,7 +6,7 @@ export * as Jwk from './jwk.ts'; import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; -import { importPbkdf2Key, deriveKeyWithPbkdf2 } from './derive.ts'; +import { importPbkdf2Key, derivePbkdf2Key } from './derive.ts'; import { generateRsaOaepKeyPair } from './crypto.ts'; const generateMnemonicPhrase = async (length: number): Promise => { @@ -34,6 +34,11 @@ export interface LocalKeyPair { private_key: string | null | undefined; } +export interface EncryptedKeyPair { + private_key: ArrayBuffer; + public_key: string; +} + export interface KeyPair { privateKey: CryptoKey; publicKey: JsonWebKey; @@ -46,13 +51,14 @@ export default class E2EE { this.#service = service; } - async loadKeys({ public_key, private_key }: RemoteKeyPair): AsyncResult { + async loadKeys(keys: EncryptedKeyPair): AsyncResult { try { - const res = await crypto.subtle.importKey('jwk', Jwk.parse(private_key), { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, [ + const privKey = toString(keys.private_key); + const res = await crypto.subtle.importKey('jwk', Jwk.parse(privKey), { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, [ 'decrypt', ]); - localStorage.setItem('public_key', public_key); - localStorage.setItem('private_key', private_key); + localStorage.setItem('public_key', keys.public_key); + localStorage.setItem('private_key', privKey); return ok(res); } catch (error) { return err(new Error('Error loading keys', { cause: error })); @@ -81,14 +87,6 @@ export default class E2EE { } } - async loadKeysFromDB(): AsyncResult { - try { - return ok(await this.#service.fetchMyKeys()); - } catch (error) { - return err(new Error('Error loading keys from service', { cause: error })); - } - } - async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { return err(new TypeError('Failed to persist keys as they are not strings.')); @@ -128,7 +126,7 @@ export default class E2EE { } } - async decodePrivateKey(privateKey: string, password: string): AsyncResult { + async decodePrivateKey(privateKey: string, password: string): AsyncResult { const masterKey = await this.getMasterKey(password); if (!masterKey.isOk) { return masterKey; @@ -138,12 +136,21 @@ export default class E2EE { try { const privKey = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, masterKey.value, cipherText); - return ok(toString(privKey)); + return ok(privKey); } catch (error) { return err(new Error('Error decrypting private key', { cause: error })); } } + async loadKeysFromDB(): AsyncResult { + try { + const keys = await this.#service.fetchMyKeys(); + return ok(keys); + } catch (error) { + return err(new Error('Error loading keys from service', { cause: error })); + } + } + /// Low-level key management methods /** @@ -189,7 +196,7 @@ export default class E2EE { // Derive a key from the password try { - return ok(await deriveKeyWithPbkdf2(userId, baseKey.value)); + return ok(await derivePbkdf2Key(userId, baseKey.value)); } catch (error) { return err(new Error(`Error deriving baseKey: ${error}`)); } diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts index a9301ab604c6b..85ddc8759d4e9 100644 --- a/packages/e2ee/src/result.ts +++ b/packages/e2ee/src/result.ts @@ -12,4 +12,11 @@ export type Result = Ok | Err; export type AsyncResult = Promise>; export const ok = (value: V): Ok => ({ isOk: true, value }); -export const err = (error: E): Err => ({ error }); \ No newline at end of file +export const err = (error: E): Err => ({ error }); + +export const unwrap = (result: Result): V => { + if (result.isOk) { + return result.value; + } + throw result.error; +}; From 6c7ddb62b13d6bb01c7e08da103379d08bd4671e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 30 Aug 2025 14:44:48 -0300 Subject: [PATCH 072/251] add coverage --- packages/e2ee/globals.d.ts | 6 +- packages/e2ee/package.json | 3 +- packages/e2ee/src/__tests__/base64.test.ts | 40 ++++--- packages/e2ee/src/__tests__/e2ee.test.ts | 3 + packages/e2ee/src/__tests__/jwk.spec.ts | 7 ++ packages/e2ee/src/base64.ts | 51 +++++---- packages/e2ee/vitest.config.ts | 13 ++- yarn.lock | 127 ++++++++++++++++++++- 8 files changed, 198 insertions(+), 52 deletions(-) diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts index c0a93d24b74d0..9b9f017821ef7 100644 --- a/packages/e2ee/globals.d.ts +++ b/packages/e2ee/globals.d.ts @@ -12,7 +12,7 @@ interface Uint8ArrayConstructor { * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last * chunk is inconsistent with the `lastChunkHandling` option. */ - fromBase64( + fromBase64?( string: string, options?: { alphabet?: 'base64' | 'base64url'; @@ -33,7 +33,7 @@ interface Uint8Array { * @param options If provided, sets the alphabet and padding behavior used. * @returns A base64-encoded string. */ - toBase64(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; + toBase64?(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; /** * Sets the `Uint8Array` from a base64-encoded string. @@ -43,7 +43,7 @@ interface Uint8Array { * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last * chunk is inconsistent with the `lastChunkHandling` option. */ - setFromBase64( + setFromBase64?( string: string, options?: { alphabet?: 'base64' | 'base64url'; diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 7ac184049635a..a05ff7c30db8d 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -20,11 +20,12 @@ "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "oxlint --type-aware", - "testunit": "vitest run", + "testunit": "vitest run --coverage", "test": "vitest" }, "devDependencies": { "@vitest/browser": "4.0.0-beta.9", + "@vitest/coverage-v8": "4.0.0-beta.9", "oxlint": "~1.14.0", "oxlint-tsgolint": "~0.1.5", "playwright": "^1.55.0", diff --git a/packages/e2ee/src/__tests__/base64.test.ts b/packages/e2ee/src/__tests__/base64.test.ts index f77d2ea33e8a1..a8c617bf57f2f 100644 --- a/packages/e2ee/src/__tests__/base64.test.ts +++ b/packages/e2ee/src/__tests__/base64.test.ts @@ -1,18 +1,28 @@ -import { expect, test } from 'vitest'; -import { parseUint8Array, stringifyUint8Array, toBase64, fromBase64 } from '../base64.ts'; +import { expect, test, describe } from 'vitest'; +import { parseUint8Array, stringifyUint8Array, fromB64, toB64, fromB64Fallback, toB64Fallback } from '../base64.ts'; -const cases = [ - ['', []], - ['AQID', [1, 2, 3]], - ['AAECAwQFBgcICQ==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], - ['AAECAwQFBgcICQoLDA0ODw==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], - ['AQIDBAUGBwgJCgsMDQ4P', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], -] as const satisfies [string: string, bytes: readonly number[]][]; +describe.for([ + { fromB64, toB64 }, + { fromB64: fromB64Fallback, toB64: toB64Fallback }, +])('Base64 (%s)', ({ fromB64, toB64 }) => { + test.for([ + ['', []], + ['AQID', [1, 2, 3]], + ['x+/y', [199, 239, 242]], + ['ZXhhZg==', [101, 120, 97, 102]], + ['AAECAwQFBgcICQ==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], + ['AAECAwQFBgcICQoLDA0ODw==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], + ['AQIDBAUGBwgJCgsMDQ4P', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], + ['SGVsbG8gV29ybGQ=', [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]], + ] as const)('"%s" === [%s]', ([string, bytes]) => { + const uint8array = new Uint8Array(bytes); + expect(fromB64(string)).toEqual(uint8array); + expect(toB64(uint8array)).toBe(string); + expect(stringifyUint8Array(uint8array)).toBe(`{"$binary":"${string}"}`); + expect(parseUint8Array(`{"$binary":"${string}"}`)).toEqual(uint8array); + }); -test.for(cases)('"%s" === [%s]', ([string, bytes]) => { - const uint8array = new Uint8Array(bytes); - expect(fromBase64(string)).toEqual(uint8array); - expect(toBase64(uint8array)).toBe(string); - expect(stringifyUint8Array(uint8array)).toBe(`{"$binary":"${string}"}`); - expect(parseUint8Array(`{"$binary":"${string}"}`)).toEqual(uint8array); + test('Invalid parseUint8Array', () => { + expect(() => parseUint8Array('[]')).toThrow(); + }); }); diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 3c3233e35503a..341f89c83e830 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -31,6 +31,9 @@ test('load user keys', async () => { expect(recoveredKeys).toHaveProperty('public_key'); expect(recoveredKeys).toHaveProperty('private_key'); + // Wrong password + expect(await bob.decodePrivateKey(recoveredKeys.private_key, 'wrong password')).toHaveProperty('error'); + // Load keys const decodedKey = unwrap(await bob.decodePrivateKey(recoveredKeys.private_key, password)); const privateKey = unwrap( diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts index 506f6664f8ba5..260ca289671aa 100644 --- a/packages/e2ee/src/__tests__/jwk.spec.ts +++ b/packages/e2ee/src/__tests__/jwk.spec.ts @@ -1,6 +1,13 @@ import * as Jwk from '../jwk.ts'; import { expect, test } from 'vitest'; +test('invalid JWK', async () => { + const jwk = { + kty: 'oct', + }; + expect(Jwk.is(jwk)).toBe(false); +}); + test('AES-CBC', async () => { const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); const jwk = await crypto.subtle.exportKey('jwk', key); diff --git a/packages/e2ee/src/base64.ts b/packages/e2ee/src/base64.ts index 30cb26851f7fc..a3266197e840a 100644 --- a/packages/e2ee/src/base64.ts +++ b/packages/e2ee/src/base64.ts @@ -1,34 +1,35 @@ -export const fromBase64: (string: string) => Uint8Array = - typeof Uint8Array.fromBase64 === 'function' - ? Uint8Array.fromBase64 - : (string) => { - const binary = atob(string); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.codePointAt(i) ?? 0; - } - return bytes; - }; +const { fromBase64 } = globalThis.Uint8Array; +const { toBase64 } = globalThis.Uint8Array.prototype; -export const toBase64: (input: Uint8Array) => string = - typeof Uint8Array.prototype.toBase64 === 'function' - ? (input) => Uint8Array.prototype.toBase64.call(input) - : (input) => { - const CHUNK_SIZE = 0x80_00; - const arr = []; - for (let i = 0; i < input.length; i += CHUNK_SIZE) { - arr.push(String.fromCodePoint(...input.subarray(i, i + CHUNK_SIZE))); - } - return btoa(arr.join('')); - }; +export const fromB64Fallback = (string: string): Uint8Array => { + const binary = atob(string); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.codePointAt(i) ?? 0; + } + return bytes; +}; + +export const fromB64: (string: string) => Uint8Array = typeof fromBase64 === 'function' ? fromBase64 : fromB64Fallback; + +export const toB64Fallback = (input: Uint8Array): string => { + const CHUNK_SIZE = 0x80_00; + const arr = []; + for (let i = 0; i < input.length; i += CHUNK_SIZE) { + arr.push(String.fromCodePoint(...input.subarray(i, i + CHUNK_SIZE))); + } + return btoa(arr.join('')); +}; + +export const toB64: (input: Uint8Array) => string = + typeof toBase64 === 'function' ? (input) => toBase64.call(input) : toB64Fallback; export const parseUint8Array = (json: string): Uint8Array => { const parsed = JSON.parse(json); if (typeof parsed !== 'object' || parsed === null || !('$binary' in parsed) || typeof parsed.$binary !== 'string') { throw new TypeError('Invalid JSON format, expected {"$binary":"..."}'); } - const binaryString = fromBase64(parsed.$binary); - return new Uint8Array(binaryString); + return fromB64(parsed.$binary); }; -export const stringifyUint8Array = (data: Uint8Array): string => `{"$binary":"${toBase64(data)}"}`; +export const stringifyUint8Array = (data: Uint8Array): string => `{"$binary":"${toB64(data)}"}`; diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index ae7113ebd8a89..281a1f7538d3b 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -1,16 +1,17 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + coverage: { + provider: 'v8', + exclude: ['**/__tests__/**'], + }, browser: { - fileParallelism: false, + screenshotFailures: false, + headless: true, enabled: true, provider: 'playwright', // https://vitest.dev/guide/browser/playwright - instances: [ - { browser: 'chromium', headless: true }, - { browser: 'firefox', headless: true }, - { browser: 'webkit', headless: true }, - ], + instances: [{ browser: 'chromium' }], }, }, }); diff --git a/yarn.lock b/yarn.lock index 716e389514b3b..a85b0b38ac173 100644 --- a/yarn.lock +++ b/yarn.lock @@ -445,6 +445,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4": + version: 7.28.3 + resolution: "@babel/parser@npm:7.28.3" + dependencies: + "@babel/types": "npm:^7.28.2" + bin: + parser: ./bin/babel-parser.js + checksum: 10/9fa08282e345b9d892a6757b2789a9a53a00f7b7b34d6254a4ee0bf32c5eb275919091ea96d6f136a948d5de9c8219235957d04a36ab7378a9d93a4cf0799155 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9" @@ -1610,6 +1621,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.28.2": + version: 7.28.2 + resolution: "@babel/types@npm:7.28.2" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10/a8de404a2e3109651f346d892dc020ce2c82046068f4ce24de7f487738dfbfa7bd716b35f1dcd6d6c32dde96208dc74a56b7f56a2c0bcb5af0ddc56cbee13533 + languageName: node + linkType: hard + "@babel/types@npm:^7.28.0": version: 7.28.0 resolution: "@babel/types@npm:7.28.0" @@ -1627,6 +1648,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10/46600b2dde460269b07a8e4f12b72e418eae1337b85c979f43af3336c9a1c65b04e42508ab6b245f1e0e3c64328e1c38d8cd733e4a7cebc4fbf9cf65c6e59937 + languageName: node + linkType: hard + "@blakek/curry@npm:^2.0.2": version: 2.0.2 resolution: "@blakek/curry@npm:2.0.2" @@ -3561,6 +3589,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.30": + version: 0.3.30 + resolution: "@jridgewell/trace-mapping@npm:0.3.30" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10/f9fabe1122058f4fedc242bdc43fcaacb6b222c6fb712b7904c37704dcb16e50e07ca137ff99043da44292b18a8720296ff97f7703e960678b8ef51d0db4d250 + languageName: node + linkType: hard + "@js-sdsl/ordered-map@npm:^4.4.2": version: 4.4.2 resolution: "@js-sdsl/ordered-map@npm:4.4.2" @@ -7360,6 +7398,7 @@ __metadata: resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: "@vitest/browser": "npm:4.0.0-beta.9" + "@vitest/coverage-v8": "npm:4.0.0-beta.9" oxlint: "npm:~1.14.0" oxlint-tsgolint: "npm:~0.1.5" playwright: "npm:^1.55.0" @@ -13394,6 +13433,31 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:4.0.0-beta.9": + version: 4.0.0-beta.9 + resolution: "@vitest/coverage-v8@npm:4.0.0-beta.9" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.0.0-beta.9" + ast-v8-to-istanbul: "npm:^0.3.3" + debug: "npm:^4.4.1" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magicast: "npm:^0.3.5" + std-env: "npm:^3.9.0" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + "@vitest/browser": 4.0.0-beta.9 + vitest: 4.0.0-beta.9 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10/964aafbc090187bbfc6eb4ee5526defe439aecef32cd14ce642749e27a0d61d24a03ef2b5f4706e5f8376d47715ac50d3701a6277bfbaff1aacef23702773598 + languageName: node + linkType: hard + "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -14600,6 +14664,17 @@ __metadata: languageName: node linkType: hard +"ast-v8-to-istanbul@npm:^0.3.3": + version: 0.3.5 + resolution: "ast-v8-to-istanbul@npm:0.3.5" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.30" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^9.0.1" + checksum: 10/5ba88ab050ea34b823808bc29784278a01c8af6d86321ad9e5a6c527eceac613aed23bee3490255e33ad30a081888c3a3bf36497fd24d4d69d81913e0a4e3567 + languageName: node + linkType: hard + "asterisk-manager@npm:^0.2.0": version: 0.2.0 resolution: "asterisk-manager@npm:0.2.0" @@ -23835,6 +23910,13 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 + languageName: node + linkType: hard + "istanbul-lib-hook@npm:^3.0.0": version: 3.0.0 resolution: "istanbul-lib-hook@npm:3.0.0" @@ -23907,6 +23989,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 + languageName: node + linkType: hard + "istanbul-lib-source-maps@npm:^4.0.0": version: 4.0.1 resolution: "istanbul-lib-source-maps@npm:4.0.1" @@ -23918,7 +24011,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.0": +"istanbul-lib-source-maps@npm:^5.0.0, istanbul-lib-source-maps@npm:^5.0.6": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" dependencies: @@ -23939,6 +24032,16 @@ __metadata: languageName: node linkType: hard +"istanbul-reports@npm:^3.1.7": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e + languageName: node + linkType: hard + "isurl@npm:^1.0.0-alpha5": version: 1.0.0 resolution: "isurl@npm:1.0.0" @@ -26292,6 +26395,17 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10/3a2dba6b0bdde957797361d09c7931ebdc1b30231705360eeb40ed458d28e1c3112841c3ed4e1b87ceb28f741e333c7673cd961193aa9fdb4f4946b202e6205a + languageName: node + linkType: hard + "mailparser@npm:^3.7.3": version: 3.7.3 resolution: "mailparser@npm:3.7.3" @@ -26349,6 +26463,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-error@npm:^1.1.1, make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -33459,7 +33582,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3 From 5bb26bc1fd7d12df2a435a56d7066a5297f064da Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 30 Aug 2025 15:28:06 -0300 Subject: [PATCH 073/251] improve test coverage --- packages/e2ee/.oxlintrc.json | 4 ++-- packages/e2ee/src/__tests__/e2ee.test.ts | 15 ++++++++++++++- packages/e2ee/src/base64.ts | 6 ++++-- packages/e2ee/src/binary.ts | 3 +-- packages/e2ee/src/derive.ts | 13 ++----------- packages/e2ee/src/index.ts | 4 ---- packages/e2ee/src/result.ts | 7 ------- packages/e2ee/vitest.config.ts | 3 ++- 8 files changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index aac49bd60ea1b..164b871e6143d 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -12,13 +12,13 @@ "pedantic": "error" }, "rules": { - "oxc/no-map-spread": "error", "oxc/no-async-await": "allow", "eslint/id-length": "allow", "eslint/no-undef": "allow", "eslint/sort-keys": "allow", "eslint/no-plusplus": "allow", "eslint/no-magic-numbers": "allow", - "eslint/no-ternary": "allow" + "eslint/no-ternary": "allow", + "unicorn/prefer-code-point": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.test.ts index 341f89c83e830..08bcc828b232e 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.test.ts @@ -1,9 +1,15 @@ import { expect, test } from 'vitest'; import E2EE from '../index.ts'; import { createDb } from './helpers/db.ts'; -import { unwrap } from '../result.ts'; +import type { Result } from '../result.ts'; const db = createDb(); +export const unwrap = (result: Result): V => { + if (result.isOk) { + return result.value; + } + throw new Error('Unwrapped a failed result'); +}; const bob = new E2EE({ fetchMyKeys: async () => { @@ -21,6 +27,9 @@ test('load user keys', async () => { expect(keys).toHaveProperty('privateKey'); const password = await bob.createRandomPassword(5); expect(password.split(' ').length).toBe(5); + expect(bob.getRandomPassword()).toBe(password); + bob.removeRandomPassword(); + expect(bob.getRandomPassword()).toBeFalsy(); const localKeys = bob.getKeysFromLocalStorage(); expect(localKeys.private_key).toBeDefined(); expect(localKeys.public_key).toBeDefined(); @@ -44,6 +53,10 @@ test('load user keys', async () => { ); expect(privateKey).toBeInstanceOf(CryptoKey); + + // Remove keys from local storage + bob.removeKeysFromLocalStorage(); + expect(bob.getKeysFromLocalStorage()).toMatchObject({ private_key: null, public_key: null }); }); // test('E2EE createRandomPassword deterministic generation with 5 words', async () => { diff --git a/packages/e2ee/src/base64.ts b/packages/e2ee/src/base64.ts index a3266197e840a..3773c007d947b 100644 --- a/packages/e2ee/src/base64.ts +++ b/packages/e2ee/src/base64.ts @@ -5,12 +5,13 @@ export const fromB64Fallback = (string: string): Uint8Array => { const binary = atob(string); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.codePointAt(i) ?? 0; + bytes[i] = binary.charCodeAt(i); } return bytes; }; -export const fromB64: (string: string) => Uint8Array = typeof fromBase64 === 'function' ? fromBase64 : fromB64Fallback; +export const fromB64: (string: string) => Uint8Array = + /* v8 ignore next -- @preserve */ typeof fromBase64 === 'function' ? fromBase64 : fromB64Fallback; export const toB64Fallback = (input: Uint8Array): string => { const CHUNK_SIZE = 0x80_00; @@ -22,6 +23,7 @@ export const toB64Fallback = (input: Uint8Array): string => { }; export const toB64: (input: Uint8Array) => string = + /* v8 ignore next -- @preserve */ typeof toBase64 === 'function' ? (input) => toBase64.call(input) : toB64Fallback; export const parseUint8Array = (json: string): Uint8Array => { diff --git a/packages/e2ee/src/binary.ts b/packages/e2ee/src/binary.ts index 3d9d57837afe4..d45b26af225c8 100644 --- a/packages/e2ee/src/binary.ts +++ b/packages/e2ee/src/binary.ts @@ -16,13 +16,12 @@ export const toString = (data: ArrayBuffer): string => { // Returns undefined if input is undefined; passes through existing ArrayBuffer; copies TypedArray/DataView. export const toArrayBuffer = (data: string): Uint8Array => { const BYTE_RANGE = 0x1_00; // 256 possible byte values - const FALLBACK_CODE_POINT = 0; const INCREMENT = 1; const len = data.length; const u8 = new Uint8Array(len); for (let i = 0; i < len; i += INCREMENT) { // Mimic old ByteBuffer 'binary' behavior: low 8 bits only - const code = data.codePointAt(i) ?? FALLBACK_CODE_POINT; + const code = data.charCodeAt(i); u8[i] = code % BYTE_RANGE; } return u8; diff --git a/packages/e2ee/src/derive.ts b/packages/e2ee/src/derive.ts index 5afe94fb5c124..9c4052a91da5b 100644 --- a/packages/e2ee/src/derive.ts +++ b/packages/e2ee/src/derive.ts @@ -15,14 +15,5 @@ export const derivePbkdf2Key = (salt: string, baseKey: CryptoKey): Promise => { - if (!password) { - throw new Error('Password is required'); - } - - const encoder = new TextEncoder(); - const dest = new Uint8Array(password.length); - encoder.encodeInto(password, dest); - - return crypto.subtle.importKey('raw', dest, { name: 'PBKDF2' }, false, ['deriveKey']); -}; +export const importPbkdf2Key = (password: string): Promise => + crypto.subtle.importKey('raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveKey']); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index e4d2aa1d8e5bd..d2d5081f6ca10 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -171,10 +171,6 @@ export default class E2EE { } async getMasterKey(password: string): AsyncResult { - if (!password) { - return err(new Error('You should provide a password')); - } - // First, create a PBKDF2 "key" containing the password const baseKey = await (async () => { try { diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts index 85ddc8759d4e9..3e940a6d33949 100644 --- a/packages/e2ee/src/result.ts +++ b/packages/e2ee/src/result.ts @@ -13,10 +13,3 @@ export type AsyncResult = Promise>; export const ok = (value: V): Ok => ({ isOk: true, value }); export const err = (error: E): Err => ({ error }); - -export const unwrap = (result: Result): V => { - if (result.isOk) { - return result.value; - } - throw result.error; -}; diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index 281a1f7538d3b..e42435d553d25 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -3,7 +3,8 @@ export default defineConfig({ test: { coverage: { provider: 'v8', - exclude: ['**/__tests__/**'], + include: ['src/*.ts'], + exclude: ['src/__tests__/**'], }, browser: { screenshotFailures: false, From cf155d09e11e7c160008f10d32c3cd2e3d4523ac Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 13:38:40 -0300 Subject: [PATCH 074/251] more tests --- packages/e2ee/src/__tests__/jwk.spec.ts | 11 +++++++++++ packages/e2ee/src/index.ts | 4 ++-- packages/e2ee/src/jwk.ts | 1 - packages/e2ee/src/{derive.ts => pbkdf2.ts} | 4 ++-- packages/e2ee/src/{crypto.ts => rsa.ts} | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) rename packages/e2ee/src/{derive.ts => pbkdf2.ts} (63%) rename packages/e2ee/src/{crypto.ts => rsa.ts} (69%) diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts index 260ca289671aa..a1f69f1924a08 100644 --- a/packages/e2ee/src/__tests__/jwk.spec.ts +++ b/packages/e2ee/src/__tests__/jwk.spec.ts @@ -1,6 +1,17 @@ import * as Jwk from '../jwk.ts'; import { expect, test } from 'vitest'; +test('parse JWK', async () => { + const jwk = Jwk.parse( + '{"kty":"RSA","e":"AQAB","n":"sXchm3y8v4j5b0kV7c1u5rX2a6pY9H3j5x7v9y3z4w5v6y7z8x9y0z1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7"}', + ); + expect(Jwk.is(jwk)).toBe(true); +}); + +test('invalid Json', async () => { + expect(() => Jwk.parse('{"kty":"RSA","e":"AQAB","n":123}')).toThrow(); +}); + test('invalid JWK', async () => { const jwk = { kty: 'oct', diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index d2d5081f6ca10..377cf418440bc 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -6,8 +6,8 @@ export * as Jwk from './jwk.ts'; import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; -import { importPbkdf2Key, derivePbkdf2Key } from './derive.ts'; -import { generateRsaOaepKeyPair } from './crypto.ts'; +import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; +import { generateRsaOaepKeyPair } from './rsa.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts index a960ac1ddfab5..f0165992c522c 100644 --- a/packages/e2ee/src/jwk.ts +++ b/packages/e2ee/src/jwk.ts @@ -55,7 +55,6 @@ const isKey = (jwk): jwk is Jwk => jwk.kty === kty && jwk.alg === alg; -/** {@link !CryptoKey} */ export const isAesGcm = (jwk: Partial): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); export const isAesCbc = (jwk: Partial): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); diff --git a/packages/e2ee/src/derive.ts b/packages/e2ee/src/pbkdf2.ts similarity index 63% rename from packages/e2ee/src/derive.ts rename to packages/e2ee/src/pbkdf2.ts index 9c4052a91da5b..93f4406c8936c 100644 --- a/packages/e2ee/src/derive.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -15,5 +15,5 @@ export const derivePbkdf2Key = (salt: string, baseKey: CryptoKey): Promise => - crypto.subtle.importKey('raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveKey']); +export const importPbkdf2Key = (passphrase: string): Promise => + crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/rsa.ts similarity index 69% rename from packages/e2ee/src/crypto.ts rename to packages/e2ee/src/rsa.ts index b28f9ebca8996..b4970aef2fe88 100644 --- a/packages/e2ee/src/crypto.ts +++ b/packages/e2ee/src/rsa.ts @@ -1,4 +1,4 @@ -export const generateRsaOaepKeyPair = async (): Promise => +export const generateRsaOaepKeyPair = (): Promise => crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ 'encrypt', 'decrypt', From 2a702f7c12e5e1f73ac4bda40db2779789d23be3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 14:35:05 -0300 Subject: [PATCH 075/251] fix legacy flaky --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 32 ++--- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 120 ++++++------------ packages/e2ee/src/index.ts | 4 +- packages/e2ee/src/pbkdf2.ts | 32 ++++- 4 files changed, 88 insertions(+), 100 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 8624a9bd1aede..b985187c6793f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { LocalKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; +import type { EncryptedKeyPair, LocalKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; import E2EE from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; @@ -195,8 +195,8 @@ class E2E extends Emitter<{ }); } - async shouldAskForE2EEPassword() { - const { private_key } = await this.e2ee.getKeysFromLocalStorage(); + shouldAskForE2EEPassword() { + const { private_key } = this.e2ee.getKeysFromLocalStorage(); return this.db_private_key && !private_key; } @@ -341,20 +341,22 @@ class E2E extends Emitter<{ this.started = true; - let { public_key, private_key } = await this.e2ee.getKeysFromLocalStorage(); + const localKeys = this.e2ee.getKeysFromLocalStorage(); const dbKeys = await this.loadKeysFromDB(); this.db_private_key = dbKeys.private_key; this.db_public_key = dbKeys.public_key; - if (!public_key && this.db_public_key) { - public_key = this.db_public_key; + if (!localKeys.public_key && this.db_public_key) { + localKeys.public_key = this.db_public_key; } - if (await this.shouldAskForE2EEPassword()) { + let privateKey; + + if (this.shouldAskForE2EEPassword()) { try { this.setState('ENTER_PASSWORD'); - private_key = await this.decodePrivateKey(this.db_private_key as string); + privateKey = await this.decodePrivateKey(this.db_private_key); } catch { this.started = false; failedToDecodeKey = true; @@ -373,8 +375,8 @@ class E2E extends Emitter<{ } } - if (public_key && private_key) { - await this.loadKeys({ public_key, private_key }); + if (localKeys.public_key && privateKey) { + await this.loadKeys({ public_key: localKeys.public_key, private_key: privateKey }); this.setState('READY'); } else { await this.createAndLoadKeys(); @@ -383,10 +385,10 @@ class E2E extends Emitter<{ if (!this.db_public_key || !this.db_private_key) { this.setState('LOADING_KEYS'); - await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), await this.e2ee.createRandomPassword(5)); + await this.persistKeys(this.e2ee.getKeysFromLocalStorage(), await this.e2ee.createRandomPassword(5)); } - const randomPassword = await this.e2ee.getRandomPassword(); + const randomPassword = this.e2ee.getRandomPassword(); if (randomPassword) { this.setState('SAVE_PASSWORD'); this.openAlert({ @@ -404,7 +406,7 @@ class E2E extends Emitter<{ logger.log('-> Stop Client'); this.closeAlert(); - await this.e2ee.removeKeysFromLocalStorage(); + this.e2ee.removeKeysFromLocalStorage(); this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; @@ -436,7 +438,7 @@ class E2E extends Emitter<{ return result.value; } - async loadKeys(keys: RemoteKeyPair): Promise { + async loadKeys(keys: EncryptedKeyPair): Promise { this.publicKey = JSON.parse(keys.public_key); const res = await this.e2ee.loadKeys(keys); if (!res.isOk) { @@ -552,7 +554,7 @@ class E2E extends Emitter<{ this.setState('READY'); } - async decodePrivateKey(privateKey: string): Promise { + async decodePrivateKey(privateKey: string): Promise { const password = await this.requestPasswordAlert(); const res = await this.e2ee.decodePrivateKey(privateKey, password); if (res.isOk) { diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 9b0f99a2053d9..bc28ed7198ec8 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -869,6 +869,47 @@ test.describe.serial('e2e-encryption', () => { await expect(page.locator('role=menuitem[name="Copy link"]')).toHaveClass(/disabled/); }); + test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { + const channelName = faker.string.uuid(); + + await restoreState(page, Users.userE2EE, { except: ['private_key', 'public_key', 'e2e.random_password'] }); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await expect(page.getByTitle('Encrypted')).toBeVisible(); + // TODO: Fix this flakiness + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); + + const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); + + // send old format encrypted message via API + const msg = await page.evaluate(async (rid) => { + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); + const e2eRoom = await e2e.getInstanceByRoomId(rid); + return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); + }, rid); + + await request.post(`${BASE_API_URL}/chat.sendMessage`, { + headers: { + 'X-Auth-Token': Users.userE2EE.data.loginToken, + 'X-User-Id': Users.userE2EE.data._id, + }, + data: { + message: { + rid, + msg, + t: 'e2e', + }, + }, + }); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + test.describe('reset keys', () => { let anotherClientPage: Page; @@ -1076,82 +1117,3 @@ test.describe.fixme('e2ee room setup', () => { await expect(poHomeChannel.tabs.btnRoomInfo).toBeVisible(); }); }); - -test.describe('e2ee support legacy formats', () => { - test.use({ storageState: Users.userE2EE.state }); - - let poHomeChannel: HomeChannel; - - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeChannel(page); - await page.goto('/home'); - - const loginPage = new LoginPage(page); - - // Wait for either the app shell or login form to render - await Promise.race([ - poHomeChannel.sidenav.btnCreateNew.waitFor({ state: 'visible' }), - loginPage.loginButton.waitFor({ state: 'visible' }), - ]).catch(() => { - /* allow follow-up check below */ - }); - - // If logged out, log back in and wait for the sidebar to be ready - if (await loginPage.loginButton.isVisible().catch(() => false)) { - await loginPage.loginByUserState(Users.userE2EE); - } - - // Ensure the sidebar is ready before interacting - await expect(poHomeChannel.sidenav.btnCreateNew).toBeVisible(); - }); - - test.beforeAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: true }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); - }); - - test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); - }); - - // ->>>>>>>>>>>Not testing upload since it was not implemented in the legacy format - test('expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { - const channelName = faker.string.uuid(); - - await poHomeChannel.sidenav.createEncryptedChannel(channelName); - - await expect(page).toHaveURL(`/group/${channelName}`); - - await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); - // TODO: Fix this flakiness - await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); - - const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); - - // send old format encrypted message via API - const msg = await page.evaluate(async (rid) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); - const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); - }, rid); - - await request.post(`${BASE_API_URL}/chat.sendMessage`, { - headers: { - 'X-Auth-Token': Users.userE2EE.data.loginToken, - 'X-User-Id': Users.userE2EE.data._id, - }, - data: { - message: { - rid, - msg, - t: 'e2e', - }, - }, - }); - - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); - await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); - }); -}); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 377cf418440bc..5c5932555c63f 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -6,7 +6,7 @@ export * as Jwk from './jwk.ts'; import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; -import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; +import { importPbkdf2Key, derivePbkdf2Key, type BaseKey } from './pbkdf2.ts'; import { generateRsaOaepKeyPair } from './rsa.ts'; const generateMnemonicPhrase = async (length: number): Promise => { @@ -170,7 +170,7 @@ export default class E2EE { localStorage.setItem('private_key', stringified); } - async getMasterKey(password: string): AsyncResult { + async getMasterKey(password: string): AsyncResult { // First, create a PBKDF2 "key" containing the password const baseKey = await (async () => { try { diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts index 93f4406c8936c..840e5785982df 100644 --- a/packages/e2ee/src/pbkdf2.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -1,9 +1,23 @@ -export const derivePbkdf2Key = (salt: string, baseKey: CryptoKey): Promise => +export type BaseKey = CryptoKey & { __pbkdf2?: true }; + +/** + * Derives a non-extractable 256-bit AES-CBC key using PBKDF2. + * + * Uses UTF-8 encoded salt, 100,000 iterations, and SHA-256 as the PRF. + * The resulting key is created with usages ['encrypt', 'decrypt'] and is not extractable. + * + * @param salt - UTF-8 string used as the PBKDF2 salt. Prefer a cryptographically random salt. + * @param baseKey - A PBKDF2-capable CryptoKey (e.g., from {@link importPbkdf2Key}). + * @returns A promise that resolves to the derived AES-CBC CryptoKey. + * @remarks Requires a Web Crypto SubtleCrypto implementation (e.g., in modern browsers). + * @see https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey + */ +export const derivePbkdf2Key = (salt: string, baseKey: BaseKey): Promise => crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new TextEncoder().encode(salt), - iterations: 10_000, + iterations: 100_000, hash: 'SHA-256', }, baseKey, @@ -15,5 +29,15 @@ export const derivePbkdf2Key = (salt: string, baseKey: CryptoKey): Promise => - crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); +/** + * Imports a passphrase as a non-extractable PBKDF2 base {@link CryptoKey}. + * + * The passphrase is UTF-8 encoded and the resulting key can only be used for 'deriveKey'. + * + * @param passphrase - Human-readable secret to be used as PBKDF2 input. + * @returns A promise that resolves to a PBKDF2 CryptoKey with usage ['deriveKey']. + * @remarks Pair this with {@link derivePbkdf2Key} to derive an encryption key. + * @see https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey + */ +export const importPbkdf2Key = (passphrase: string): Promise => + crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']) as Promise; From b806805f28b0fb36087bcfb5eee71ff8abe0b283 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 15:57:30 -0300 Subject: [PATCH 076/251] renames --- packages/e2ee/docs/NOTES.md | 1 + .../__tests__/{e2ee.test.ts => e2ee.spec.ts} | 9 - packages/e2ee/src/__tests__/pbkdf2.spec.ts | 8 + .../{vector.test.ts => vector.spec.ts} | 0 packages/e2ee/src/index.ts | 16 +- packages/e2ee/src/pbkdf2.ts | 5 +- packages/e2ee/src/rsa.ts | 3 + packages/e2ee/src/word-list.ts | 3269 ++++++++--------- 8 files changed, 1657 insertions(+), 1654 deletions(-) rename packages/e2ee/src/__tests__/{e2ee.test.ts => e2ee.spec.ts} (79%) create mode 100644 packages/e2ee/src/__tests__/pbkdf2.spec.ts rename packages/e2ee/src/__tests__/{vector.test.ts => vector.spec.ts} (100%) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 4e81aa639e863..f60dfd4c92b35 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -3,6 +3,7 @@ ## History - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) +- [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) diff --git a/packages/e2ee/src/__tests__/e2ee.test.ts b/packages/e2ee/src/__tests__/e2ee.spec.ts similarity index 79% rename from packages/e2ee/src/__tests__/e2ee.test.ts rename to packages/e2ee/src/__tests__/e2ee.spec.ts index 08bcc828b232e..3becc3521848f 100644 --- a/packages/e2ee/src/__tests__/e2ee.test.ts +++ b/packages/e2ee/src/__tests__/e2ee.spec.ts @@ -58,12 +58,3 @@ test('load user keys', async () => { bob.removeKeysFromLocalStorage(); expect(bob.getKeysFromLocalStorage()).toMatchObject({ private_key: null, public_key: null }); }); - -// test('E2EE createRandomPassword deterministic generation with 5 words', async () => { -// // Inject custom word list by temporarily defining dynamic import. -// // Instead, we monkey patch global import for wordList path using dynamic import map isn't trivial here. -// // So we skip testing exact phrase (depends on wordList) and just assert shape. -// const pwd = await bob.createRandomPassword(5); -// expect(pwd.split(' ').length).toBe(5); -// expect(bob.getRandomPassword()).toBe(pwd); -// }); diff --git a/packages/e2ee/src/__tests__/pbkdf2.spec.ts b/packages/e2ee/src/__tests__/pbkdf2.spec.ts new file mode 100644 index 0000000000000..49f8f8c663d99 --- /dev/null +++ b/packages/e2ee/src/__tests__/pbkdf2.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from 'vitest'; +import { derivePbkdf2Key, importPbkdf2Key } from '../pbkdf2.ts'; + +test('pbkdf2', async () => { + const baseKey = await importPbkdf2Key('test-keys'); + const derived = await derivePbkdf2Key('stest-key', baseKey); + expect(derived.algorithm.name).toBe('AES-CBC'); +}); diff --git a/packages/e2ee/src/__tests__/vector.test.ts b/packages/e2ee/src/__tests__/vector.spec.ts similarity index 100% rename from packages/e2ee/src/__tests__/vector.test.ts rename to packages/e2ee/src/__tests__/vector.spec.ts diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 5c5932555c63f..a27e70eb5acb6 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -6,8 +6,8 @@ export * as Jwk from './jwk.ts'; import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; -import { importPbkdf2Key, derivePbkdf2Key, type BaseKey } from './pbkdf2.ts'; -import { generateRsaOaepKeyPair } from './rsa.ts'; +import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; +import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); @@ -54,9 +54,11 @@ export default class E2EE { async loadKeys(keys: EncryptedKeyPair): AsyncResult { try { const privKey = toString(keys.private_key); - const res = await crypto.subtle.importKey('jwk', Jwk.parse(privKey), { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, [ - 'decrypt', - ]); + const privKeyJwk = Jwk.parse(privKey); + if (!Jwk.isRsaOaep(privKeyJwk)) { + return err(new TypeError('Private key is not a valid RSA-OAEP JWK')); + } + const res = await importRsaOaepKey(privKeyJwk); localStorage.setItem('public_key', keys.public_key); localStorage.setItem('private_key', privKey); return ok(res); @@ -170,7 +172,7 @@ export default class E2EE { localStorage.setItem('private_key', stringified); } - async getMasterKey(password: string): AsyncResult { + async getMasterKey(password: string): AsyncResult { // First, create a PBKDF2 "key" containing the password const baseKey = await (async () => { try { @@ -194,7 +196,7 @@ export default class E2EE { try { return ok(await derivePbkdf2Key(userId, baseKey.value)); } catch (error) { - return err(new Error(`Error deriving baseKey: ${error}`)); + return err(new Error('Error deriving baseKey', { cause: error })); } } diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts index 840e5785982df..c6766a7b145ae 100644 --- a/packages/e2ee/src/pbkdf2.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -1,4 +1,5 @@ -export type BaseKey = CryptoKey & { __pbkdf2?: true }; +declare const brand: unique symbol; +export type BaseKey = CryptoKey & { [brand]: 'pbkdf2' }; /** * Derives a non-extractable 256-bit AES-CBC key using PBKDF2. @@ -37,7 +38,7 @@ export const derivePbkdf2Key = (salt: string, baseKey: BaseKey): Promise => crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']) as Promise; diff --git a/packages/e2ee/src/rsa.ts b/packages/e2ee/src/rsa.ts index b4970aef2fe88..32b722ba4d779 100644 --- a/packages/e2ee/src/rsa.ts +++ b/packages/e2ee/src/rsa.ts @@ -3,3 +3,6 @@ export const generateRsaOaepKeyPair = (): Promise => 'encrypt', 'decrypt', ]); + +export const importRsaOaepKey = (jwk: JsonWebKey & { kty: 'RSA'; alg: 'RSA-OAEP-256' }): Promise => + crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']); diff --git a/packages/e2ee/src/word-list.ts b/packages/e2ee/src/word-list.ts index 40cb3247f90f6..8386533d6d424 100644 --- a/packages/e2ee/src/word-list.ts +++ b/packages/e2ee/src/word-list.ts @@ -1,1636 +1,1633 @@ -// oxlint-disable max-lines -export const v1: string[] = [ - 'acrobat', - 'africa', - 'alaska', - 'albert', - 'albino', - 'album', - 'alcohol', - 'alex', - 'alpha', - 'amadeus', - 'amanda', - 'amazon', - 'america', - 'analog', - 'animal', - 'antenna', - 'antonio', - 'apollo', - 'april', - 'aroma', - 'artist', - 'aspirin', - 'athlete', - 'atlas', - 'banana', - 'bandit', - 'banjo', - 'bikini', - 'bingo', - 'bonus', - 'camera', - 'canada', - 'carbon', - 'casino', - 'catalog', - 'cinema', - 'citizen', - 'cobra', - 'comet', - 'compact', - 'complex', - 'context', - 'credit', - 'critic', - 'crystal', - 'culture', - 'david', - 'delta', - 'dialog', - 'diploma', - 'doctor', - 'domino', - 'dragon', - 'drama', - 'extra', - 'fabric', - 'final', - 'focus', - 'forum', - 'galaxy', - 'gallery', - 'global', - 'harmony', - 'hotel', - 'humor', - 'index', - 'japan', - 'kilo', - 'lemon', - 'liter', - 'lotus', - 'mango', - 'melon', - 'menu', - 'meter', - 'metro', - 'mineral', - 'model', - 'music', - 'object', - 'piano', - 'pirate', - 'plastic', - 'radio', - 'report', - 'signal', - 'sport', - 'studio', - 'subject', - 'super', - 'tango', - 'taxi', - 'tempo', - 'tennis', - 'textile', - 'tokyo', - 'total', - 'tourist', - 'video', - 'visa', - 'academy', - 'alfred', - 'atlanta', - 'atomic', - 'barbara', - 'bazaar', - 'brother', - 'budget', - 'cabaret', - 'cadet', - 'candle', - 'capsule', - 'caviar', - 'channel', - 'chapter', - 'circle', - 'cobalt', - 'comrade', - 'condor', - 'crimson', - 'cyclone', - 'darwin', - 'declare', - 'denver', - 'desert', - 'divide', - 'dolby', - 'domain', - 'double', - 'eagle', - 'echo', - 'eclipse', - 'editor', - 'educate', - 'edward', - 'effect', - 'electra', - 'emerald', - 'emotion', - 'empire', - 'eternal', - 'evening', - 'exhibit', - 'expand', - 'explore', - 'extreme', - 'ferrari', - 'forget', - 'freedom', - 'friday', - 'fuji', - 'galileo', - 'genesis', - 'gravity', - 'habitat', - 'hamlet', - 'harlem', - 'helium', - 'holiday', - 'hunter', - 'ibiza', - 'iceberg', - 'imagine', - 'infant', - 'isotope', - 'jackson', - 'jamaica', - 'jasmine', - 'java', - 'jessica', - 'kitchen', - 'lazarus', - 'letter', - 'license', - 'lithium', - 'loyal', - 'lucky', - 'magenta', - 'manual', - 'marble', - 'maxwell', - 'mayor', - 'monarch', - 'monday', - 'money', - 'morning', - 'mother', - 'mystery', - 'native', - 'nectar', - 'nelson', - 'network', - 'nikita', - 'nobel', - 'nobody', - 'nominal', - 'norway', - 'nothing', - 'number', - 'october', - 'office', - 'oliver', - 'opinion', - 'option', - 'order', - 'outside', - 'package', - 'pandora', - 'panther', - 'papa', - 'pattern', - 'pedro', - 'pencil', - 'people', - 'phantom', - 'philips', - 'pioneer', - 'pluto', - 'podium', - 'portal', - 'potato', - 'process', - 'proxy', - 'pupil', - 'python', - 'quality', - 'quarter', - 'quiet', - 'rabbit', - 'radical', - 'radius', - 'rainbow', - 'ramirez', - 'ravioli', - 'raymond', - 'respect', - 'respond', - 'result', - 'resume', - 'richard', - 'river', - 'roger', - 'roman', - 'rondo', - 'sabrina', - 'salary', - 'salsa', - 'sample', - 'samuel', - 'saturn', - 'savage', - 'scarlet', - 'scorpio', - 'sector', - 'serpent', - 'shampoo', - 'sharon', - 'silence', - 'simple', - 'society', - 'sonar', - 'sonata', - 'soprano', - 'sparta', - 'spider', - 'sponsor', - 'abraham', - 'action', - 'active', - 'actor', - 'adam', - 'address', - 'admiral', - 'adrian', - 'agenda', - 'agent', - 'airline', - 'airport', - 'alabama', - 'aladdin', - 'alarm', - 'algebra', - 'alibi', - 'alice', - 'alien', - 'almond', - 'alpine', - 'amber', - 'amigo', - 'ammonia', - 'analyze', - 'anatomy', - 'angel', - 'annual', - 'answer', - 'apple', - 'archive', - 'arctic', - 'arena', - 'arizona', - 'armada', - 'arnold', - 'arsenal', - 'arthur', - 'asia', - 'aspect', - 'athena', - 'audio', - 'august', - 'austria', - 'avenue', - 'average', - 'axiom', - 'aztec', - 'bagel', - 'baker', - 'balance', - 'ballad', - 'ballet', - 'bambino', - 'bamboo', - 'baron', - 'basic', - 'basket', - 'battery', - 'belgium', - 'benefit', - 'berlin', - 'bermuda', - 'bernard', - 'bicycle', - 'binary', - 'biology', - 'bishop', - 'blitz', - 'block', - 'blonde', - 'bonjour', - 'boris', - 'boston', - 'bottle', - 'boxer', - 'brandy', - 'bravo', - 'brazil', - 'bridge', - 'british', - 'bronze', - 'brown', - 'bruce', - 'bruno', - 'brush', - 'burger', - 'burma', - 'cabinet', - 'cactus', - 'cafe', - 'cairo', - 'calypso', - 'camel', - 'campus', - 'canal', - 'cannon', - 'canoe', - 'cantina', - 'canvas', - 'canyon', - 'capital', - 'caramel', - 'caravan', - 'career', - 'cargo', - 'carlo', - 'carol', - 'carpet', - 'cartel', - 'cartoon', - 'castle', - 'castro', - 'cecilia', - 'cement', - 'center', - 'century', - 'ceramic', - 'chamber', - 'chance', - 'change', - 'chaos', - 'charlie', - 'charm', - 'charter', - 'cheese', - 'chef', - 'chemist', - 'cherry', - 'chess', - 'chicago', - 'chicken', - 'chief', - 'china', - 'cigar', - 'circus', - 'city', - 'clara', - 'classic', - 'claudia', - 'clean', - 'client', - 'climax', - 'clinic', - 'clock', - 'club', - 'cockpit', - 'coconut', - 'cola', - 'collect', - 'colombo', - 'colony', - 'color', - 'combat', - 'comedy', - 'command', - 'company', - 'concert', - 'connect', - 'consul', - 'contact', - 'contour', - 'control', - 'convert', - 'copy', - 'corner', - 'corona', - 'correct', - 'cosmos', - 'couple', - 'courage', - 'cowboy', - 'craft', - 'crash', - 'cricket', - 'crown', - 'cuba', - 'dallas', - 'dance', - 'daniel', - 'decade', - 'decimal', - 'degree', - 'delete', - 'deliver', - 'delphi', - 'deluxe', - 'demand', - 'demo', - 'denmark', - 'derby', - 'design', - 'detect', - 'develop', - 'diagram', - 'diamond', - 'diana', - 'diego', - 'diesel', - 'diet', - 'digital', - 'dilemma', - 'direct', - 'disco', - 'disney', - 'distant', - 'dollar', - 'dolphin', - 'donald', - 'drink', - 'driver', - 'dublin', - 'duet', - 'dynamic', - 'earth', - 'east', - 'ecology', - 'economy', - 'edgar', - 'egypt', - 'elastic', - 'elegant', - 'element', - 'elite', - 'elvis', - 'email', - 'empty', - 'energy', - 'engine', - 'english', - 'episode', - 'equator', - 'escape', - 'escort', - 'ethnic', - 'europe', - 'everest', - 'evident', - 'exact', - 'example', - 'exit', - 'exotic', - 'export', - 'express', - 'factor', - 'falcon', - 'family', - 'fantasy', - 'fashion', - 'fiber', - 'fiction', - 'fidel', - 'fiesta', - 'figure', - 'film', - 'filter', - 'finance', - 'finish', - 'finland', - 'first', - 'flag', - 'flash', - 'florida', - 'flower', - 'fluid', - 'flute', - 'folio', - 'ford', - 'forest', - 'formal', - 'formula', - 'fortune', - 'forward', - 'fragile', - 'france', - 'frank', - 'fresh', - 'friend', - 'frozen', - 'future', - 'gabriel', - 'gamma', - 'garage', - 'garcia', - 'garden', - 'garlic', - 'gemini', - 'general', - 'genetic', - 'genius', - 'germany', - 'gloria', - 'gold', - 'golf', - 'gondola', - 'gong', - 'good', - 'gordon', - 'gorilla', - 'grand', - 'granite', - 'graph', - 'green', - 'group', - 'guide', - 'guitar', - 'guru', - 'hand', - 'happy', - 'harbor', - 'harvard', - 'havana', - 'hawaii', - 'helena', - 'hello', - 'henry', - 'hilton', - 'history', - 'horizon', - 'house', - 'human', - 'icon', - 'idea', - 'igloo', - 'igor', - 'image', - 'impact', - 'import', - 'india', - 'indigo', - 'input', - 'insect', - 'instant', - 'iris', - 'italian', - 'jacket', - 'jacob', - 'jaguar', - 'janet', - 'jargon', - 'jazz', - 'jeep', - 'john', - 'joker', - 'jordan', - 'judo', - 'jumbo', - 'june', - 'jungle', - 'junior', - 'jupiter', - 'karate', - 'karma', - 'kayak', - 'kermit', - 'king', - 'koala', - 'korea', - 'labor', - 'lady', - 'lagoon', - 'laptop', - 'laser', - 'latin', - 'lava', - 'lecture', - 'left', - 'legal', - 'level', - 'lexicon', - 'liberal', - 'libra', - 'lily', - 'limbo', - 'limit', - 'linda', - 'linear', - 'lion', - 'liquid', - 'little', - 'llama', - 'lobby', - 'lobster', - 'local', - 'logic', - 'logo', - 'lola', - 'london', - 'lucas', - 'lunar', - 'machine', - 'macro', - 'madam', - 'madonna', - 'madrid', - 'maestro', - 'magic', - 'magnet', - 'magnum', - 'mailbox', - 'major', - 'mama', - 'mambo', - 'manager', - 'manila', - 'marco', - 'marina', - 'market', - 'mars', - 'martin', - 'marvin', - 'mary', - 'master', - 'matrix', - 'maximum', - 'media', - 'medical', - 'mega', - 'melody', - 'memo', - 'mental', - 'mentor', - 'mercury', - 'message', - 'metal', - 'meteor', - 'method', - 'mexico', - 'miami', - 'micro', - 'milk', - 'million', - 'minimum', - 'minus', - 'minute', - 'miracle', - 'mirage', - 'miranda', - 'mister', - 'mixer', - 'mobile', - 'modem', - 'modern', - 'modular', - 'moment', - 'monaco', - 'monica', - 'monitor', - 'mono', - 'monster', - 'montana', - 'morgan', - 'motel', - 'motif', - 'motor', - 'mozart', - 'multi', - 'museum', - 'mustang', - 'natural', - 'neon', - 'nepal', - 'neptune', - 'nerve', - 'neutral', - 'nevada', - 'news', - 'next', - 'ninja', - 'nirvana', - 'normal', - 'nova', - 'novel', - 'nuclear', - 'numeric', - 'nylon', - 'oasis', - 'observe', - 'ocean', - 'octopus', - 'olivia', - 'olympic', - 'omega', - 'opera', - 'optic', - 'optimal', - 'orange', - 'orbit', - 'organic', - 'orient', - 'origin', - 'orlando', - 'oscar', - 'oxford', - 'oxygen', - 'ozone', - 'pablo', - 'pacific', - 'pagoda', - 'palace', - 'pamela', - 'panama', - 'pancake', - 'panda', - 'panel', - 'panic', - 'paradox', - 'pardon', - 'paris', - 'parker', - 'parking', - 'parody', - 'partner', - 'passage', - 'passive', - 'pasta', - 'pastel', - 'patent', - 'patient', - 'patriot', - 'patrol', - 'pegasus', - 'pelican', - 'penguin', - 'pepper', - 'percent', - 'perfect', - 'perfume', - 'period', - 'permit', - 'person', - 'peru', - 'phone', - 'photo', - 'picasso', - 'picnic', - 'picture', - 'pigment', - 'pilgrim', - 'pilot', - 'pixel', - 'pizza', - 'planet', - 'plasma', - 'plaza', - 'pocket', - 'poem', - 'poetic', - 'poker', - 'polaris', - 'police', - 'politic', - 'polo', - 'polygon', - 'pony', - 'popcorn', - 'popular', - 'postage', - 'precise', - 'prefix', - 'premium', - 'present', - 'price', - 'prince', - 'printer', - 'prism', - 'private', - 'prize', - 'product', - 'profile', - 'program', - 'project', - 'protect', - 'proton', - 'public', - 'pulse', - 'puma', - 'pump', - 'pyramid', - 'queen', - 'radar', - 'ralph', - 'random', - 'rapid', - 'rebel', - 'record', - 'recycle', - 'reflex', - 'reform', - 'regard', - 'regular', - 'relax', - 'reptile', - 'reverse', - 'ricardo', - 'right', - 'ringo', - 'risk', - 'ritual', - 'robert', - 'robot', - 'rocket', - 'rodeo', - 'romeo', - 'royal', - 'russian', - 'safari', - 'salad', - 'salami', - 'salmon', - 'salon', - 'salute', - 'samba', - 'sandra', - 'santana', - 'sardine', - 'school', - 'scoop', - 'scratch', - 'screen', - 'script', - 'scroll', - 'second', - 'secret', - 'section', - 'segment', - 'select', - 'seminar', - 'senator', - 'senior', - 'sensor', - 'serial', - 'service', - 'shadow', - 'sharp', - 'sheriff', - 'shock', - 'short', - 'shrink', - 'sierra', - 'silicon', - 'silk', - 'silver', - 'similar', - 'simon', - 'single', - 'siren', - 'slang', - 'slogan', - 'smart', - 'smoke', - 'snake', - 'social', - 'soda', - 'solar', - 'solid', - 'solo', - 'sonic', - 'source', - 'soviet', - 'special', - 'speed', - 'sphere', - 'spiral', - 'spirit', - 'spring', - 'static', - 'status', - 'stereo', - 'stone', - 'stop', - 'street', - 'strong', - 'student', - 'style', - 'sultan', - 'susan', - 'sushi', - 'suzuki', - 'switch', - 'symbol', - 'system', - 'tactic', - 'tahiti', - 'talent', - 'tarzan', - 'telex', - 'texas', - 'theory', - 'thermos', - 'tiger', - 'titanic', - 'tomato', - 'topic', - 'tornado', - 'toronto', - 'torpedo', - 'totem', - 'tractor', - 'traffic', - 'transit', - 'trapeze', - 'travel', - 'tribal', - 'trick', - 'trident', - 'trilogy', - 'tripod', - 'tropic', - 'trumpet', - 'tulip', - 'tuna', - 'turbo', - 'twist', - 'ultra', - 'uniform', - 'union', - 'uranium', - 'vacuum', - 'valid', - 'vampire', - 'vanilla', - 'vatican', - 'velvet', - 'ventura', - 'venus', - 'vertigo', - 'veteran', - 'victor', - 'vienna', - 'viking', - 'village', - 'vincent', - 'violet', - 'violin', - 'virtual', - 'virus', - 'vision', - 'visitor', - 'visual', - 'vitamin', - 'viva', - 'vocal', - 'vodka', - 'volcano', - 'voltage', - 'volume', - 'voyage', - 'water', - 'weekend', - 'welcome', - 'western', - 'window', - 'winter', - 'wizard', - 'wolf', - 'world', - 'xray', - 'yankee', - 'yoga', - 'yogurt', - 'yoyo', - 'zebra', - 'zero', - 'zigzag', - 'zipper', - 'zodiac', - 'zoom', - 'acid', - 'adios', - 'agatha', - 'alamo', - 'alert', - 'almanac', - 'aloha', - 'andrea', - 'anita', - 'arcade', - 'aurora', - 'avalon', - 'baby', - 'baggage', - 'balloon', - 'bank', - 'basil', - 'begin', - 'biscuit', - 'blue', - 'bombay', - 'botanic', - 'brain', - 'brenda', - 'brigade', - 'cable', - 'calibre', - 'carmen', - 'cello', - 'celtic', - 'chariot', - 'chrome', - 'citrus', - 'civil', - 'cloud', - 'combine', - 'common', - 'cool', - 'copper', - 'coral', - 'crater', - 'cubic', - 'cupid', - 'cycle', - 'depend', - 'door', - 'dream', - 'dynasty', - 'edison', - 'edition', - 'enigma', - 'equal', - 'eric', - 'event', - 'evita', - 'exodus', - 'extend', - 'famous', - 'farmer', - 'food', - 'fossil', - 'frog', - 'fruit', - 'geneva', - 'gentle', - 'george', - 'giant', - 'gilbert', - 'gossip', - 'gram', - 'greek', - 'grille', - 'hammer', - 'harvest', - 'hazard', - 'heaven', - 'herbert', - 'heroic', - 'hexagon', - 'husband', - 'immune', - 'inca', - 'inch', - 'initial', - 'isabel', - 'ivory', - 'jason', - 'jerome', - 'joel', - 'joshua', - 'journal', - 'judge', - 'juliet', - 'jump', - 'justice', - 'kimono', - 'kinetic', - 'leonid', - 'leopard', - 'lima', - 'maze', - 'medusa', - 'member', - 'memphis', - 'michael', - 'miguel', - 'milan', - 'mile', - 'miller', - 'mimic', - 'mimosa', - 'mission', - 'monkey', - 'moral', - 'moses', - 'mouse', - 'nancy', - 'natasha', - 'nebula', - 'nickel', - 'nina', - 'noise', - 'orchid', - 'oregano', - 'origami', - 'orinoco', - 'orion', - 'othello', - 'paper', - 'paprika', - 'prelude', - 'prepare', - 'pretend', - 'promise', - 'prosper', - 'provide', - 'puzzle', - 'remote', - 'repair', - 'reply', - 'rival', - 'riviera', - 'robin', - 'rose', - 'rover', - 'rudolf', - 'saga', - 'sahara', - 'scholar', - 'shelter', - 'ship', - 'shoe', - 'sigma', - 'sister', - 'sleep', - 'smile', - 'spain', - 'spark', - 'split', - 'spray', - 'square', - 'stadium', - 'star', - 'storm', - 'story', - 'strange', - 'stretch', - 'stuart', - 'subway', - 'sugar', - 'sulfur', - 'summer', - 'survive', - 'sweet', - 'swim', - 'table', - 'taboo', - 'target', - 'teacher', - 'telecom', - 'temple', - 'tibet', - 'ticket', - 'tina', - 'today', - 'toga', - 'tommy', - 'tower', - 'trivial', - 'tunnel', - 'turtle', - 'twin', - 'uncle', - 'unicorn', - 'unique', - 'update', - 'valery', - 'vega', - 'version', - 'voodoo', - 'warning', - 'william', - 'wonder', - 'year', - 'yellow', - 'young', - 'absent', - 'absorb', - 'absurd', - 'accent', - 'alfonso', - 'alias', - 'ambient', - 'anagram', - 'andy', - 'anvil', - 'appear', - 'apropos', - 'archer', - 'ariel', - 'armor', - 'arrow', - 'austin', - 'avatar', - 'axis', - 'baboon', - 'bahama', - 'bali', - 'balsa', - 'barcode', - 'bazooka', - 'beach', - 'beast', - 'beatles', - 'beauty', - 'before', - 'benny', - 'betty', - 'between', - 'beyond', - 'billy', - 'bison', - 'blast', - 'bless', - 'bogart', - 'bonanza', - 'book', - 'border', - 'brave', - 'bread', - 'break', - 'broken', - 'bucket', - 'buenos', - 'buffalo', - 'bundle', - 'button', - 'buzzer', - 'byte', - 'caesar', - 'camilla', - 'canary', - 'candid', - 'carrot', - 'cave', - 'chant', - 'child', - 'choice', - 'chris', - 'cipher', - 'clarion', - 'clark', - 'clever', - 'cliff', - 'clone', - 'conan', - 'conduct', - 'congo', - 'costume', - 'cotton', - 'cover', - 'crack', - 'current', - 'danube', - 'data', - 'decide', - 'deposit', - 'desire', - 'detail', - 'dexter', - 'dinner', - 'donor', - 'druid', - 'drum', - 'easy', - 'eddie', - 'enjoy', - 'enrico', - 'epoxy', - 'erosion', - 'except', - 'exile', - 'explain', - 'fame', - 'fast', - 'father', - 'felix', - 'field', - 'fiona', - 'fire', - 'fish', - 'flame', - 'flex', - 'flipper', - 'float', - 'flood', - 'floor', - 'forbid', - 'forever', - 'fractal', - 'frame', - 'freddie', - 'front', - 'fuel', - 'gallop', - 'game', - 'garbo', - 'gate', - 'gelatin', - 'gibson', - 'ginger', - 'giraffe', - 'gizmo', - 'glass', - 'goblin', - 'gopher', - 'grace', - 'gray', - 'gregory', - 'grid', - 'griffin', - 'ground', - 'guest', - 'gustav', - 'gyro', - 'hair', - 'halt', - 'harris', - 'heart', - 'heavy', - 'herman', - 'hippie', - 'hobby', - 'honey', - 'hope', - 'horse', - 'hostel', - 'hydro', - 'imitate', - 'info', - 'ingrid', - 'inside', - 'invent', - 'invest', - 'invite', - 'ivan', - 'james', - 'jester', - 'jimmy', - 'join', - 'joseph', - 'juice', - 'julius', - 'july', - 'kansas', - 'karl', - 'kevin', - 'kiwi', - 'ladder', - 'lake', - 'laura', - 'learn', - 'legacy', - 'legend', - 'lesson', - 'life', - 'light', - 'list', - 'locate', - 'lopez', - 'lorenzo', - 'love', - 'lunch', - 'malta', - 'mammal', - 'margin', - 'margo', - 'marion', - 'mask', - 'match', - 'mayday', - 'meaning', - 'mercy', - 'middle', - 'mike', - 'mirror', - 'modest', - 'morph', - 'morris', - 'mystic', - 'nadia', - 'nato', - 'navy', - 'needle', - 'neuron', - 'never', - 'newton', - 'nice', - 'night', - 'nissan', - 'nitro', - 'nixon', - 'north', - 'oberon', - 'octavia', - 'ohio', - 'olga', - 'open', - 'opus', - 'orca', - 'oval', - 'owner', - 'page', - 'paint', - 'palma', - 'parent', - 'parlor', - 'parole', - 'paul', - 'peace', - 'pearl', - 'perform', - 'phoenix', - 'phrase', - 'pierre', - 'pinball', - 'place', - 'plate', - 'plato', - 'plume', - 'pogo', - 'point', - 'polka', - 'poncho', - 'powder', - 'prague', - 'press', - 'presto', - 'pretty', - 'prime', - 'promo', - 'quest', - 'quick', - 'quiz', - 'quota', - 'race', - 'rachel', - 'raja', - 'ranger', - 'region', - 'remark', - 'rent', - 'reward', - 'rhino', - 'ribbon', - 'rider', - 'road', - 'rodent', - 'round', - 'rubber', - 'ruby', - 'rufus', - 'sabine', - 'saddle', - 'sailor', - 'saint', - 'salt', - 'scale', - 'scuba', - 'season', - 'secure', - 'shake', - 'shallow', - 'shannon', - 'shave', - 'shelf', - 'sherman', - 'shine', - 'shirt', - 'side', - 'sinatra', - 'sincere', - 'size', - 'slalom', - 'slow', - 'small', - 'snow', - 'sofia', - 'song', - 'sound', - 'south', - 'speech', - 'spell', - 'spend', - 'spoon', - 'stage', - 'stamp', - 'stand', - 'state', - 'stella', - 'stick', - 'sting', - 'stock', - 'store', - 'sunday', - 'sunset', - 'support', - 'supreme', - 'sweden', - 'swing', - 'tape', - 'tavern', - 'think', - 'thomas', - 'tictac', - 'time', - 'toast', - 'tobacco', - 'tonight', - 'torch', - 'torso', - 'touch', - 'toyota', - 'trade', - 'tribune', - 'trinity', - 'triton', - 'truck', - 'trust', - 'type', - 'under', - 'unit', - 'urban', - 'urgent', - 'user', - 'value', - 'vendor', - 'venice', - 'verona', - 'vibrate', - 'virgo', - 'visible', - 'vista', - 'vital', - 'voice', - 'vortex', - 'waiter', - 'watch', - 'wave', - 'weather', - 'wedding', - 'wheel', - 'whiskey', - 'wisdom', - 'android', - 'annex', - 'armani', - 'cake', - 'confide', - 'deal', - 'define', - 'dispute', - 'genuine', - 'idiom', - 'impress', - 'include', - 'ironic', - 'null', - 'nurse', - 'obscure', - 'prefer', - 'prodigy', - 'ego', - 'fax', - 'jet', - 'job', - 'rio', - 'ski', - 'yes', -]; +export const v1: string[] = `acrobat +africa +alaska +albert +albino +album +alcohol +alex +alpha +amadeus +amanda +amazon +america +analog +animal +antenna +antonio +apollo +april +aroma +artist +aspirin +athlete +atlas +banana +bandit +banjo +bikini +bingo +bonus +camera +canada +carbon +casino +catalog +cinema +citizen +cobra +comet +compact +complex +context +credit +critic +crystal +culture +david +delta +dialog +diploma +doctor +domino +dragon +drama +extra +fabric +final +focus +forum +galaxy +gallery +global +harmony +hotel +humor +index +japan +kilo +lemon +liter +lotus +mango +melon +menu +meter +metro +mineral +model +music +object +piano +pirate +plastic +radio +report +signal +sport +studio +subject +super +tango +taxi +tempo +tennis +textile +tokyo +total +tourist +video +visa +academy +alfred +atlanta +atomic +barbara +bazaar +brother +budget +cabaret +cadet +candle +capsule +caviar +channel +chapter +circle +cobalt +comrade +condor +crimson +cyclone +darwin +declare +denver +desert +divide +dolby +domain +double +eagle +echo +eclipse +editor +educate +edward +effect +electra +emerald +emotion +empire +eternal +evening +exhibit +expand +explore +extreme +ferrari +forget +freedom +friday +fuji +galileo +genesis +gravity +habitat +hamlet +harlem +helium +holiday +hunter +ibiza +iceberg +imagine +infant +isotope +jackson +jamaica +jasmine +java +jessica +kitchen +lazarus +letter +license +lithium +loyal +lucky +magenta +manual +marble +maxwell +mayor +monarch +monday +money +morning +mother +mystery +native +nectar +nelson +network +nikita +nobel +nobody +nominal +norway +nothing +number +october +office +oliver +opinion +option +order +outside +package +pandora +panther +papa +pattern +pedro +pencil +people +phantom +philips +pioneer +pluto +podium +portal +potato +process +proxy +pupil +python +quality +quarter +quiet +rabbit +radical +radius +rainbow +ramirez +ravioli +raymond +respect +respond +result +resume +richard +river +roger +roman +rondo +sabrina +salary +salsa +sample +samuel +saturn +savage +scarlet +scorpio +sector +serpent +shampoo +sharon +silence +simple +society +sonar +sonata +soprano +sparta +spider +sponsor +abraham +action +active +actor +adam +address +admiral +adrian +agenda +agent +airline +airport +alabama +aladdin +alarm +algebra +alibi +alice +alien +almond +alpine +amber +amigo +ammonia +analyze +anatomy +angel +annual +answer +apple +archive +arctic +arena +arizona +armada +arnold +arsenal +arthur +asia +aspect +athena +audio +august +austria +avenue +average +axiom +aztec +bagel +baker +balance +ballad +ballet +bambino +bamboo +baron +basic +basket +battery +belgium +benefit +berlin +bermuda +bernard +bicycle +binary +biology +bishop +blitz +block +blonde +bonjour +boris +boston +bottle +boxer +brandy +bravo +brazil +bridge +british +bronze +brown +bruce +bruno +brush +burger +burma +cabinet +cactus +cafe +cairo +calypso +camel +campus +canal +cannon +canoe +cantina +canvas +canyon +capital +caramel +caravan +career +cargo +carlo +carol +carpet +cartel +cartoon +castle +castro +cecilia +cement +center +century +ceramic +chamber +chance +change +chaos +charlie +charm +charter +cheese +chef +chemist +cherry +chess +chicago +chicken +chief +china +cigar +circus +city +clara +classic +claudia +clean +client +climax +clinic +clock +club +cockpit +coconut +cola +collect +colombo +colony +color +combat +comedy +command +company +concert +connect +consul +contact +contour +control +convert +copy +corner +corona +correct +cosmos +couple +courage +cowboy +craft +crash +cricket +crown +cuba +dallas +dance +daniel +decade +decimal +degree +delete +deliver +delphi +deluxe +demand +demo +denmark +derby +design +detect +develop +diagram +diamond +diana +diego +diesel +diet +digital +dilemma +direct +disco +disney +distant +dollar +dolphin +donald +drink +driver +dublin +duet +dynamic +earth +east +ecology +economy +edgar +egypt +elastic +elegant +element +elite +elvis +email +empty +energy +engine +english +episode +equator +escape +escort +ethnic +europe +everest +evident +exact +example +exit +exotic +export +express +factor +falcon +family +fantasy +fashion +fiber +fiction +fidel +fiesta +figure +film +filter +finance +finish +finland +first +flag +flash +florida +flower +fluid +flute +folio +ford +forest +formal +formula +fortune +forward +fragile +france +frank +fresh +friend +frozen +future +gabriel +gamma +garage +garcia +garden +garlic +gemini +general +genetic +genius +germany +gloria +gold +golf +gondola +gong +good +gordon +gorilla +grand +granite +graph +green +group +guide +guitar +guru +hand +happy +harbor +harvard +havana +hawaii +helena +hello +henry +hilton +history +horizon +house +human +icon +idea +igloo +igor +image +impact +import +india +indigo +input +insect +instant +iris +italian +jacket +jacob +jaguar +janet +jargon +jazz +jeep +john +joker +jordan +judo +jumbo +june +jungle +junior +jupiter +karate +karma +kayak +kermit +king +koala +korea +labor +lady +lagoon +laptop +laser +latin +lava +lecture +left +legal +level +lexicon +liberal +libra +lily +limbo +limit +linda +linear +lion +liquid +little +llama +lobby +lobster +local +logic +logo +lola +london +lucas +lunar +machine +macro +madam +madonna +madrid +maestro +magic +magnet +magnum +mailbox +major +mama +mambo +manager +manila +marco +marina +market +mars +martin +marvin +mary +master +matrix +maximum +media +medical +mega +melody +memo +mental +mentor +mercury +message +metal +meteor +method +mexico +miami +micro +milk +million +minimum +minus +minute +miracle +mirage +miranda +mister +mixer +mobile +modem +modern +modular +moment +monaco +monica +monitor +mono +monster +montana +morgan +motel +motif +motor +mozart +multi +museum +mustang +natural +neon +nepal +neptune +nerve +neutral +nevada +news +next +ninja +nirvana +normal +nova +novel +nuclear +numeric +nylon +oasis +observe +ocean +octopus +olivia +olympic +omega +opera +optic +optimal +orange +orbit +organic +orient +origin +orlando +oscar +oxford +oxygen +ozone +pablo +pacific +pagoda +palace +pamela +panama +pancake +panda +panel +panic +paradox +pardon +paris +parker +parking +parody +partner +passage +passive +pasta +pastel +patent +patient +patriot +patrol +pegasus +pelican +penguin +pepper +percent +perfect +perfume +period +permit +person +peru +phone +photo +picasso +picnic +picture +pigment +pilgrim +pilot +pixel +pizza +planet +plasma +plaza +pocket +poem +poetic +poker +polaris +police +politic +polo +polygon +pony +popcorn +popular +postage +precise +prefix +premium +present +price +prince +printer +prism +private +prize +product +profile +program +project +protect +proton +public +pulse +puma +pump +pyramid +queen +radar +ralph +random +rapid +rebel +record +recycle +reflex +reform +regard +regular +relax +reptile +reverse +ricardo +right +ringo +risk +ritual +robert +robot +rocket +rodeo +romeo +royal +russian +safari +salad +salami +salmon +salon +salute +samba +sandra +santana +sardine +school +scoop +scratch +screen +script +scroll +second +secret +section +segment +select +seminar +senator +senior +sensor +serial +service +shadow +sharp +sheriff +shock +short +shrink +sierra +silicon +silk +silver +similar +simon +single +siren +slang +slogan +smart +smoke +snake +social +soda +solar +solid +solo +sonic +source +soviet +special +speed +sphere +spiral +spirit +spring +static +status +stereo +stone +stop +street +strong +student +style +sultan +susan +sushi +suzuki +switch +symbol +system +tactic +tahiti +talent +tarzan +telex +texas +theory +thermos +tiger +titanic +tomato +topic +tornado +toronto +torpedo +totem +tractor +traffic +transit +trapeze +travel +tribal +trick +trident +trilogy +tripod +tropic +trumpet +tulip +tuna +turbo +twist +ultra +uniform +union +uranium +vacuum +valid +vampire +vanilla +vatican +velvet +ventura +venus +vertigo +veteran +victor +vienna +viking +village +vincent +violet +violin +virtual +virus +vision +visitor +visual +vitamin +viva +vocal +vodka +volcano +voltage +volume +voyage +water +weekend +welcome +western +window +winter +wizard +wolf +world +xray +yankee +yoga +yogurt +yoyo +zebra +zero +zigzag +zipper +zodiac +zoom +acid +adios +agatha +alamo +alert +almanac +aloha +andrea +anita +arcade +aurora +avalon +baby +baggage +balloon +bank +basil +begin +biscuit +blue +bombay +botanic +brain +brenda +brigade +cable +calibre +carmen +cello +celtic +chariot +chrome +citrus +civil +cloud +combine +common +cool +copper +coral +crater +cubic +cupid +cycle +depend +door +dream +dynasty +edison +edition +enigma +equal +eric +event +evita +exodus +extend +famous +farmer +food +fossil +frog +fruit +geneva +gentle +george +giant +gilbert +gossip +gram +greek +grille +hammer +harvest +hazard +heaven +herbert +heroic +hexagon +husband +immune +inca +inch +initial +isabel +ivory +jason +jerome +joel +joshua +journal +judge +juliet +jump +justice +kimono +kinetic +leonid +leopard +lima +maze +medusa +member +memphis +michael +miguel +milan +mile +miller +mimic +mimosa +mission +monkey +moral +moses +mouse +nancy +natasha +nebula +nickel +nina +noise +orchid +oregano +origami +orinoco +orion +othello +paper +paprika +prelude +prepare +pretend +promise +prosper +provide +puzzle +remote +repair +reply +rival +riviera +robin +rose +rover +rudolf +saga +sahara +scholar +shelter +ship +shoe +sigma +sister +sleep +smile +spain +spark +split +spray +square +stadium +star +storm +story +strange +stretch +stuart +subway +sugar +sulfur +summer +survive +sweet +swim +table +taboo +target +teacher +telecom +temple +tibet +ticket +tina +today +toga +tommy +tower +trivial +tunnel +turtle +twin +uncle +unicorn +unique +update +valery +vega +version +voodoo +warning +william +wonder +year +yellow +young +absent +absorb +absurd +accent +alfonso +alias +ambient +anagram +andy +anvil +appear +apropos +archer +ariel +armor +arrow +austin +avatar +axis +baboon +bahama +bali +balsa +barcode +bazooka +beach +beast +beatles +beauty +before +benny +betty +between +beyond +billy +bison +blast +bless +bogart +bonanza +book +border +brave +bread +break +broken +bucket +buenos +buffalo +bundle +button +buzzer +byte +caesar +camilla +canary +candid +carrot +cave +chant +child +choice +chris +cipher +clarion +clark +clever +cliff +clone +conan +conduct +congo +costume +cotton +cover +crack +current +danube +data +decide +deposit +desire +detail +dexter +dinner +donor +druid +drum +easy +eddie +enjoy +enrico +epoxy +erosion +except +exile +explain +fame +fast +father +felix +field +fiona +fire +fish +flame +flex +flipper +float +flood +floor +forbid +forever +fractal +frame +freddie +front +fuel +gallop +game +garbo +gate +gelatin +gibson +ginger +giraffe +gizmo +glass +goblin +gopher +grace +gray +gregory +grid +griffin +ground +guest +gustav +gyro +hair +halt +harris +heart +heavy +herman +hippie +hobby +honey +hope +horse +hostel +hydro +imitate +info +ingrid +inside +invent +invest +invite +ivan +james +jester +jimmy +join +joseph +juice +julius +july +kansas +karl +kevin +kiwi +ladder +lake +laura +learn +legacy +legend +lesson +life +light +list +locate +lopez +lorenzo +love +lunch +malta +mammal +margin +margo +marion +mask +match +mayday +meaning +mercy +middle +mike +mirror +modest +morph +morris +mystic +nadia +nato +navy +needle +neuron +never +newton +nice +night +nissan +nitro +nixon +north +oberon +octavia +ohio +olga +open +opus +orca +oval +owner +page +paint +palma +parent +parlor +parole +paul +peace +pearl +perform +phoenix +phrase +pierre +pinball +place +plate +plato +plume +pogo +point +polka +poncho +powder +prague +press +presto +pretty +prime +promo +quest +quick +quiz +quota +race +rachel +raja +ranger +region +remark +rent +reward +rhino +ribbon +rider +road +rodent +round +rubber +ruby +rufus +sabine +saddle +sailor +saint +salt +scale +scuba +season +secure +shake +shallow +shannon +shave +shelf +sherman +shine +shirt +side +sinatra +sincere +size +slalom +slow +small +snow +sofia +song +sound +south +speech +spell +spend +spoon +stage +stamp +stand +state +stella +stick +sting +stock +store +sunday +sunset +support +supreme +sweden +swing +tape +tavern +think +thomas +tictac +time +toast +tobacco +tonight +torch +torso +touch +toyota +trade +tribune +trinity +triton +truck +trust +type +under +unit +urban +urgent +user +value +vendor +venice +verona +vibrate +virgo +visible +vista +vital +voice +vortex +waiter +watch +wave +weather +wedding +wheel +whiskey +wisdom +android +annex +armani +cake +confide +deal +define +dispute +genuine +idiom +impress +include +ironic +null +nurse +obscure +prefer +prodigy +ego +fax +jet +job +rio +ski +yes`.split('\n'); From a2b88ed740c8bb1d7ac9302f2974df4232f866ba Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 16:21:35 -0300 Subject: [PATCH 077/251] fix lint --- packages/e2ee/.oxlintrc.json | 3 ++- packages/e2ee/globals.d.ts | 3 ++- packages/e2ee/playground/.oxlintrc.json | 5 +++++ packages/e2ee/playground/messages.mongodb.js | 1 - packages/e2ee/src/__tests__/helpers/db.ts | 4 ++-- packages/e2ee/src/index.ts | 6 +++--- packages/e2ee/src/pbkdf2.ts | 20 +++++++++++++------- packages/e2ee/src/rsa.ts | 19 ++++++++++++------- 8 files changed, 39 insertions(+), 22 deletions(-) create mode 100644 packages/e2ee/playground/.oxlintrc.json diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index 164b871e6143d..e22b85329f18a 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -19,6 +19,7 @@ "eslint/no-plusplus": "allow", "eslint/no-magic-numbers": "allow", "eslint/no-ternary": "allow", - "unicorn/prefer-code-point": "allow" + "unicorn/prefer-code-point": "allow", + "eslint/max-lines": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts index 9b9f017821ef7..df9cd32e2274a 100644 --- a/packages/e2ee/globals.d.ts +++ b/packages/e2ee/globals.d.ts @@ -13,6 +13,7 @@ interface Uint8ArrayConstructor { * chunk is inconsistent with the `lastChunkHandling` option. */ fromBase64?( + this: void, string: string, options?: { alphabet?: 'base64' | 'base64url'; @@ -33,7 +34,7 @@ interface Uint8Array { * @param options If provided, sets the alphabet and padding behavior used. * @returns A base64-encoded string. */ - toBase64?(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; + toBase64?(this: void, options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; /** * Sets the `Uint8Array` from a base64-encoded string. diff --git a/packages/e2ee/playground/.oxlintrc.json b/packages/e2ee/playground/.oxlintrc.json new file mode 100644 index 0000000000000..329fae1135eb5 --- /dev/null +++ b/packages/e2ee/playground/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-magic-numbers": "allow" + } +} \ No newline at end of file diff --git a/packages/e2ee/playground/messages.mongodb.js b/packages/e2ee/playground/messages.mongodb.js index f7b031779461e..9083bf045f3a7 100644 --- a/packages/e2ee/playground/messages.mongodb.js +++ b/packages/e2ee/playground/messages.mongodb.js @@ -1,4 +1,3 @@ -// oxlint-disable no-undef use('meteor'); db.getCollection('rocketchat_message').find({ // 'content.algorithm': { $exists: true }, diff --git a/packages/e2ee/src/__tests__/helpers/db.ts b/packages/e2ee/src/__tests__/helpers/db.ts index d11ad1d4b935e..f45230cddb8dd 100644 --- a/packages/e2ee/src/__tests__/helpers/db.ts +++ b/packages/e2ee/src/__tests__/helpers/db.ts @@ -16,11 +16,11 @@ class Db { } async set(userId: string, data: Data, force: boolean): Promise { - return await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { setTimeout(() => { if (this.data.has(userId)) { if (!force) { - return reject(new Error('User already exists')); + reject(new Error('User already exists')); } else { this.data.set(userId, data); resolve(); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index a27e70eb5acb6..7f6b99da88038 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -178,7 +178,7 @@ export default class E2EE { try { return ok(await importPbkdf2Key(password)); } catch (error) { - return err(new Error(`Error creating a key based on user password: ${error}`)); + return err(new Error('Error creating a key based on user password', { cause: error })); } })(); @@ -215,7 +215,7 @@ export default class E2EE { } storeRandomPassword(randomPassword: string): void { - return localStorage.setItem('e2e.random_password', randomPassword); + localStorage.setItem('e2e.random_password', randomPassword); } getRandomPassword(): string | null { @@ -223,7 +223,7 @@ export default class E2EE { } removeRandomPassword(): void { - return localStorage.removeItem('e2e.random_password'); + localStorage.removeItem('e2e.random_password'); } async createRandomPassword(length: number): Promise { const randomPassword = await generateMnemonicPhrase(length); diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts index c6766a7b145ae..c4ac71a32fc12 100644 --- a/packages/e2ee/src/pbkdf2.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -1,5 +1,6 @@ -declare const brand: unique symbol; -export type BaseKey = CryptoKey & { [brand]: 'pbkdf2' }; +export interface BaseKey { + key: CryptoKey; +} /** * Derives a non-extractable 256-bit AES-CBC key using PBKDF2. @@ -13,15 +14,15 @@ export type BaseKey = CryptoKey & { [brand]: 'pbkdf2' }; * @remarks Requires a Web Crypto SubtleCrypto implementation (e.g., in modern browsers). * @see https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey */ -export const derivePbkdf2Key = (salt: string, baseKey: BaseKey): Promise => - crypto.subtle.deriveKey( +export const derivePbkdf2Key = async (salt: string, baseKey: BaseKey): Promise => { + const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new TextEncoder().encode(salt), iterations: 100_000, hash: 'SHA-256', }, - baseKey, + baseKey.key, { name: 'AES-CBC', length: 256, @@ -30,6 +31,9 @@ export const derivePbkdf2Key = (salt: string, baseKey: BaseKey): Promise => - crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']) as Promise; +export const importPbkdf2Key = async (passphrase: string): Promise => { + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); + return { key }; +}; diff --git a/packages/e2ee/src/rsa.ts b/packages/e2ee/src/rsa.ts index 32b722ba4d779..10b2d22821ba9 100644 --- a/packages/e2ee/src/rsa.ts +++ b/packages/e2ee/src/rsa.ts @@ -1,8 +1,13 @@ -export const generateRsaOaepKeyPair = (): Promise => - crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, [ - 'encrypt', - 'decrypt', - ]); +export const generateRsaOaepKeyPair = async (): Promise => { + const keyPair = await crypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ); + return keyPair; +}; -export const importRsaOaepKey = (jwk: JsonWebKey & { kty: 'RSA'; alg: 'RSA-OAEP-256' }): Promise => - crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']); +export const importRsaOaepKey = async (jwk: JsonWebKey & { kty: 'RSA'; alg: 'RSA-OAEP-256' }): Promise => { + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']); + return key; +}; From 49b217354ef02807f78c097d3f68d632c1f8868e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 17:07:32 -0300 Subject: [PATCH 078/251] add rsa tests --- packages/e2ee/package.json | 4 +- .../{base64.test.ts => base64.spec.ts} | 0 .../{binary.test.ts => binary.spec.ts} | 0 packages/e2ee/src/__tests__/rsa.spec.ts | 77 +++++++++++++++++++ packages/e2ee/src/index.ts | 2 +- packages/e2ee/src/rsa.ts | 25 +++++- packages/e2ee/vitest.config.ts | 2 +- 7 files changed, 103 insertions(+), 7 deletions(-) rename packages/e2ee/src/__tests__/{base64.test.ts => base64.spec.ts} (100%) rename packages/e2ee/src/__tests__/{binary.test.ts => binary.spec.ts} (100%) create mode 100644 packages/e2ee/src/__tests__/rsa.spec.ts diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index a05ff7c30db8d..710ef46686e63 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -20,8 +20,8 @@ "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", "typecheck": "tsc -p tsconfig.json", "lint": "oxlint --type-aware", - "testunit": "vitest run --coverage", - "test": "vitest" + "testunit": "vitest run --browser=chromium --coverage", + "test": "vitest run" }, "devDependencies": { "@vitest/browser": "4.0.0-beta.9", diff --git a/packages/e2ee/src/__tests__/base64.test.ts b/packages/e2ee/src/__tests__/base64.spec.ts similarity index 100% rename from packages/e2ee/src/__tests__/base64.test.ts rename to packages/e2ee/src/__tests__/base64.spec.ts diff --git a/packages/e2ee/src/__tests__/binary.test.ts b/packages/e2ee/src/__tests__/binary.spec.ts similarity index 100% rename from packages/e2ee/src/__tests__/binary.test.ts rename to packages/e2ee/src/__tests__/binary.spec.ts diff --git a/packages/e2ee/src/__tests__/rsa.spec.ts b/packages/e2ee/src/__tests__/rsa.spec.ts new file mode 100644 index 0000000000000..0b00152ba99c3 --- /dev/null +++ b/packages/e2ee/src/__tests__/rsa.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from 'vitest'; +import { generateRsaOaepKeyPair, importRsaOaepKey, exportRsaOaepKey, decryptRsaOaep, encryptRsaOaep } from '../rsa'; + +const encode = (s: string) => new TextEncoder().encode(s); +const decode = (b: ArrayBuffer | Uint8Array) => new TextDecoder().decode(b instanceof Uint8Array ? b : new Uint8Array(b)); + +test('generate, encrypt with public key, and decrypt with private key', async () => { + const { rsa } = await generateRsaOaepKeyPair(); + const { publicKey, privateKey } = rsa; + + const plaintext = 'Hello RSA-OAEP'; + const data = encode(plaintext); + + const ciphertext = await encryptRsaOaep(publicKey, data); + expect(ciphertext).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(ciphertext).byteLength).toBe(256); // 2048-bit modulus + + const decrypted = await decryptRsaOaep(privateKey, new Uint8Array(ciphertext)); + expect(decode(decrypted)).toBe(plaintext); + expect(new Uint8Array(decrypted).byteLength).toBe(data.byteLength); +}, 20000); + +test('export private key to JWK, import back, imported key is non-extractable and usable', async () => { + const { rsa } = await generateRsaOaepKeyPair(); + const { publicKey, privateKey } = rsa; + + const jwk = await exportRsaOaepKey(privateKey); + expect(jwk.kty).toBe('RSA'); + expect('d' in jwk).toBe(true); + + const importedPrivate = await importRsaOaepKey(jwk); + expect(importedPrivate.type).toBe('private'); + expect(importedPrivate.algorithm && (importedPrivate.algorithm as any).name).toBe('RSA-OAEP'); + + await expect(exportRsaOaepKey(importedPrivate)).rejects.toBeDefined(); + + const msg = 'Roundtrip after import'; + const ct = await encryptRsaOaep(publicKey, encode(msg)); + const pt = await decryptRsaOaep(importedPrivate, new Uint8Array(ct)); + expect(decode(pt)).toBe(msg); +}, 20000); + +test('importRsaOaepKey rejects when given a public JWK for decrypt usage', async () => { + const { rsa } = await generateRsaOaepKeyPair(); + const { publicKey } = rsa; + + const publicJwk = await exportRsaOaepKey(publicKey); + expect(publicJwk.kty).toBe('RSA'); + expect('d' in publicJwk).toBe(false); + + await expect(importRsaOaepKey(publicJwk)).rejects.toBeDefined(); +}, 10000); + +test('encrypt with private key rejects; decrypt with public key rejects', async () => { + const { rsa } = await generateRsaOaepKeyPair(); + const { publicKey, privateKey } = rsa; + + const data = encode('invalid ops'); + + await expect(encryptRsaOaep(privateKey, data)).rejects.toBeDefined(); + + const ct = await encryptRsaOaep(publicKey, data); + await expect(decryptRsaOaep(publicKey, new Uint8Array(ct))).rejects.toBeDefined(); +}, 15000); + +test('tampering with ciphertext causes decryption to fail', async () => { + const { rsa } = await generateRsaOaepKeyPair(); + const { publicKey, privateKey } = rsa; + + const data = encode('Do not tamper'); + + const ctBuf = await encryptRsaOaep(publicKey, data); + const tampered = new Uint8Array(ctBuf); + tampered[0]! ^= 0xff; + + await expect(decryptRsaOaep(privateKey, tampered)).rejects.toBeDefined(); +}, 15000); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 7f6b99da88038..982907e1f0b63 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -69,7 +69,7 @@ export default class E2EE { async createAndLoadKeys(): AsyncResult { try { - const keys = await generateRsaOaepKeyPair(); + const { rsa: keys } = await generateRsaOaepKeyPair(); try { const publicKey = await this.setPublicKey(keys.publicKey); try { diff --git a/packages/e2ee/src/rsa.ts b/packages/e2ee/src/rsa.ts index 10b2d22821ba9..cdf50cc1d8221 100644 --- a/packages/e2ee/src/rsa.ts +++ b/packages/e2ee/src/rsa.ts @@ -1,13 +1,32 @@ -export const generateRsaOaepKeyPair = async (): Promise => { +export interface RsaKeyPair { + rsa: CryptoKeyPair; +} + +export const generateRsaOaepKeyPair = async (): Promise => { const keyPair = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['encrypt', 'decrypt'], ); - return keyPair; + return { rsa: keyPair }; }; -export const importRsaOaepKey = async (jwk: JsonWebKey & { kty: 'RSA'; alg: 'RSA-OAEP-256' }): Promise => { +export const importRsaOaepKey = async (jwk: JsonWebKey): Promise => { const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']); return key; }; + +export const exportRsaOaepKey = async (key: CryptoKey): Promise => { + const jwk = await crypto.subtle.exportKey('jwk', key); + return jwk; +}; + +export const decryptRsaOaep = async (key: CryptoKey, data: Uint8Array): Promise => { + const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); + return decrypted; +}; + +export const encryptRsaOaep = async (key: CryptoKey, data: Uint8Array): Promise => { + const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); + return encrypted; +}; diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index e42435d553d25..e080248b38c49 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ enabled: true, provider: 'playwright', // https://vitest.dev/guide/browser/playwright - instances: [{ browser: 'chromium' }], + instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], }, }, }); From 721ad9b586a490bf7cf3921641fa3a9fdf6e31db Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 17:30:08 -0300 Subject: [PATCH 079/251] add aes --- apps/meteor/client/lib/e2ee/helper.ts | 12 +++---- packages/e2ee/globals.d.ts | 2 +- packages/e2ee/src/__tests__/e2ee.spec.ts | 2 +- packages/e2ee/src/__tests__/pbkdf2.spec.ts | 4 +-- packages/e2ee/src/aes.ts | 37 ++++++++++++++++++++++ packages/e2ee/src/pbkdf2.ts | 12 +++---- 6 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 packages/e2ee/src/aes.ts diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index a6eb057765dad..fe68e47e73c2f 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -48,6 +48,10 @@ export async function encryptAesCbc(vector: Uint8Array, key: Crypto return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } +export async function decryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { + return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); +} + export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); } @@ -64,14 +68,6 @@ export async function decryptRSA(key: CryptoKey, data: Uint8Array) return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } -export async function decryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { - return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); -} - -/** - * Generates a new AES-CBC key. - * @deprecated Use {@link generateAesGcmKey} instead. - */ export async function generateAesCbcKey() { return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); } diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts index df9cd32e2274a..c7284376cd52c 100644 --- a/packages/e2ee/globals.d.ts +++ b/packages/e2ee/globals.d.ts @@ -34,7 +34,7 @@ interface Uint8Array { * @param options If provided, sets the alphabet and padding behavior used. * @returns A base64-encoded string. */ - toBase64?(this: void, options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; + toBase64?(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; /** * Sets the `Uint8Array` from a base64-encoded string. diff --git a/packages/e2ee/src/__tests__/e2ee.spec.ts b/packages/e2ee/src/__tests__/e2ee.spec.ts index 3becc3521848f..7779d0a3ff515 100644 --- a/packages/e2ee/src/__tests__/e2ee.spec.ts +++ b/packages/e2ee/src/__tests__/e2ee.spec.ts @@ -8,7 +8,7 @@ export const unwrap = (result: Result): V => { if (result.isOk) { return result.value; } - throw new Error('Unwrapped a failed result'); + throw result; }; const bob = new E2EE({ diff --git a/packages/e2ee/src/__tests__/pbkdf2.spec.ts b/packages/e2ee/src/__tests__/pbkdf2.spec.ts index 49f8f8c663d99..a19f12b3228ca 100644 --- a/packages/e2ee/src/__tests__/pbkdf2.spec.ts +++ b/packages/e2ee/src/__tests__/pbkdf2.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from 'vitest'; import { derivePbkdf2Key, importPbkdf2Key } from '../pbkdf2.ts'; test('pbkdf2', async () => { - const baseKey = await importPbkdf2Key('test-keys'); - const derived = await derivePbkdf2Key('stest-key', baseKey); + const baseKey = await importPbkdf2Key('passphrase'); + const derived = await derivePbkdf2Key('salt', baseKey); expect(derived.algorithm.name).toBe('AES-CBC'); }); diff --git a/packages/e2ee/src/aes.ts b/packages/e2ee/src/aes.ts new file mode 100644 index 0000000000000..db8762c57974e --- /dev/null +++ b/packages/e2ee/src/aes.ts @@ -0,0 +1,37 @@ +export const generateAesCbcKey = async (): Promise => { + const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); + return key; +}; + +export const encryptAesCbc = async ( + vector: Uint8Array, + key: CryptoKey, + data: Uint8Array, +): Promise => { + const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); + return encrypted; +}; + +export const decryptAesCbc = async ( + vector: Uint8Array, + key: CryptoKey, + data: Uint8Array, +): Promise => { + const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); + return decrypted; +}; + +export const generateAesGcmKey = async (): Promise => { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + return key; +}; + +export const encryptAesGcm = async (iv: Uint8Array, key: CryptoKey, data: BufferSource): Promise => { + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); + return encrypted; +}; + +export const decryptAesGcm = async (iv: Uint8Array, key: CryptoKey, data: Uint8Array): Promise => { + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); + return decrypted; +}; diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts index c4ac71a32fc12..f0fe85731b378 100644 --- a/packages/e2ee/src/pbkdf2.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -1,7 +1,3 @@ -export interface BaseKey { - key: CryptoKey; -} - /** * Derives a non-extractable 256-bit AES-CBC key using PBKDF2. * @@ -14,7 +10,7 @@ export interface BaseKey { * @remarks Requires a Web Crypto SubtleCrypto implementation (e.g., in modern browsers). * @see https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey */ -export const derivePbkdf2Key = async (salt: string, baseKey: BaseKey): Promise => { +export const derivePbkdf2Key = async (salt: string, baseKey: CryptoKey): Promise => { const derivedKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', @@ -22,7 +18,7 @@ export const derivePbkdf2Key = async (salt: string, baseKey: BaseKey): Promise => { +export const importPbkdf2Key = async (passphrase: string): Promise => { const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); - return { key }; + return key; }; From c022ede01492425f3726330e67efb1d85503450e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 17:36:09 -0300 Subject: [PATCH 080/251] fix tests --- packages/e2ee/src/aes.ts | 5 ----- packages/e2ee/src/index.ts | 5 +++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/e2ee/src/aes.ts b/packages/e2ee/src/aes.ts index db8762c57974e..499fb3905b856 100644 --- a/packages/e2ee/src/aes.ts +++ b/packages/e2ee/src/aes.ts @@ -1,8 +1,3 @@ -export const generateAesCbcKey = async (): Promise => { - const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); - return key; -}; - export const encryptAesCbc = async ( vector: Uint8Array, key: CryptoKey, diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 982907e1f0b63..bdb9684fd8434 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -8,6 +8,7 @@ import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; +import { decryptAesCbc, encryptAesCbc } from './aes.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); @@ -119,7 +120,7 @@ export default class E2EE { const vector = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); try { const privateKeyBuffer = toArrayBuffer(privateKey); - const encryptedPrivateKey = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, masterKey.value, privateKeyBuffer); + const encryptedPrivateKey = await encryptAesCbc(vector, masterKey.value, privateKeyBuffer); const joined = joinVectorAndEncryptedData(vector, encryptedPrivateKey); const stringified = stringifyUint8Array(joined); return ok(stringified); @@ -137,7 +138,7 @@ export default class E2EE { const [vector, cipherText] = splitVectorAndEncryptedData(parseUint8Array(privateKey)); try { - const privKey = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, masterKey.value, cipherText); + const privKey = await decryptAesCbc(vector, masterKey.value, cipherText); return ok(privKey); } catch (error) { return err(new Error('Error decrypting private key', { cause: error })); From bcc61a7d9dc328b10017e60415e0921ca2f0d4e8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 31 Aug 2025 20:23:02 -0300 Subject: [PATCH 081/251] add backfill spec --- packages/e2ee/src/__tests__/backfill.spec.ts | 85 ++++++++++++++++ packages/e2ee/src/__tests__/group.spec.ts | 43 ++++++++ packages/e2ee/src/__tests__/pbkdf2.spec.ts | 2 +- packages/e2ee/src/__tests__/rsa.spec.ts | 15 +-- packages/e2ee/src/group.ts | 100 +++++++++++++++++++ packages/e2ee/src/index.ts | 8 +- packages/e2ee/src/pbkdf2.ts | 2 +- packages/e2ee/src/rsa.ts | 10 +- 8 files changed, 242 insertions(+), 23 deletions(-) create mode 100644 packages/e2ee/src/__tests__/backfill.spec.ts create mode 100644 packages/e2ee/src/__tests__/group.spec.ts create mode 100644 packages/e2ee/src/group.ts diff --git a/packages/e2ee/src/__tests__/backfill.spec.ts b/packages/e2ee/src/__tests__/backfill.spec.ts new file mode 100644 index 0000000000000..7303656aea89c --- /dev/null +++ b/packages/e2ee/src/__tests__/backfill.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from 'vitest'; +import { decryptForGroup, decryptMessage, rekeyGroupAndEncrypt, type EncryptedData } from '../group'; +import { generateRsaOaepKeyPair } from '../rsa'; + +type SymmetricKey = { key: ArrayBuffer; messageIndex: number }; + +// In a real application, these would be stored encrypted in a database. +const serverStorage = { + messages: [] as EncryptedData[], + symmetricKeys: [] as SymmetricKey[], +}; + +// --- ENCRYPTION AND STORAGE (Simulated server-side) --- +async function encryptAndStore(plaintext: string, recipientPublicKeys: CryptoKey[]) { + // 1. Generate new AES key and IV + const aesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // 2. Encrypt the plaintext message + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, aesKey, new TextEncoder().encode(plaintext)); + + // 3. Store the encrypted message and IV on the server + serverStorage.messages.push({ ciphertext, iv }); + + // 4. Extract and store the raw symmetric key on the server (encrypted) + const rawAesKey = await crypto.subtle.exportKey('raw', aesKey); + serverStorage.symmetricKeys.push({ key: rawAesKey, messageIndex: serverStorage.messages.length - 1 }); + + // 5. Wrap the raw key for each recipient and distribute + const wrappedKeys = await Promise.all(recipientPublicKeys.map((key) => crypto.subtle.wrapKey('raw', aesKey, key, { name: 'RSA-OAEP' }))); + return { ciphertext, wrappedKeys, iv }; +} + +// --- BACKFILLING FOR A NEW USER (Simulated on server) --- +async function createBackfillPackage(newRecipientPublicKey: CryptoKey) { + const wrappedSymmetricKeys = await Promise.all( + serverStorage.symmetricKeys.map(async (keyObject) => { + const keyToWrap = await crypto.subtle.importKey('raw', keyObject.key, 'AES-GCM', true, ['wrapKey']); + const wrappedKey = await crypto.subtle.wrapKey('raw', keyToWrap, newRecipientPublicKey, { name: 'RSA-OAEP' }); + return { wrappedKey, messageIndex: keyObject.messageIndex }; + }), + ); + + return { + messages: serverStorage.messages, + wrappedSymmetricKeys: wrappedSymmetricKeys, + }; +} + +test('backfilling', async () => { + // --- Step 1: Initialize group with Alice and Bob --- + const aliceKeyPair = await generateRsaOaepKeyPair(); + const bobKeyPair = await generateRsaOaepKeyPair(); + let groupMembers = [aliceKeyPair, bobKeyPair]; + let recipientPublicKeys = groupMembers.map((m) => m.publicKey); + + // --- Step 2: Simulate initial messages from Alice and Bob --- + await encryptAndStore('Hello from Alice!', recipientPublicKeys); + await encryptAndStore("Hey Alice, how's it going?", recipientPublicKeys); + + // --- Step 3: Add new user, Charlie --- + const charlieKeyPair = await generateRsaOaepKeyPair(); + groupMembers.push(charlieKeyPair); + recipientPublicKeys = groupMembers.map((m) => m.publicKey); + + // --- Step 4: Create backfill package for Charlie --- + const charlieBackfillPackage = await createBackfillPackage(charlieKeyPair.publicKey); + + // --- Step 5: Charlie decrypts all messages --- + const decryptedMessages: string[] = []; + for (const wrappedKeyInfo of charlieBackfillPackage.wrappedSymmetricKeys) { + const messageIndex = wrappedKeyInfo.messageIndex; + const messageData = charlieBackfillPackage.messages[messageIndex]!; + const decryptedMessage = await decryptMessage(messageData, charlieKeyPair.privateKey, wrappedKeyInfo.wrappedKey); + decryptedMessages.push(decryptedMessage); + } + + expect(decryptedMessages).toEqual(['Hello from Alice!', "Hey Alice, how's it going?"]); + + // --- Step 6: Send new message with all members --- + const newMessage = 'Welcome Charlie! This is a new message.'; + const newEncryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, newMessage); + const decryptedByCharlie = await decryptForGroup(newEncryptedPackage, charlieKeyPair.privateKey, 2); + expect(decryptedByCharlie).toBe(newMessage); +}); diff --git a/packages/e2ee/src/__tests__/group.spec.ts b/packages/e2ee/src/__tests__/group.spec.ts new file mode 100644 index 0000000000000..6c06e050b9ef8 --- /dev/null +++ b/packages/e2ee/src/__tests__/group.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from 'vitest'; +import { generateRsaOaepKeyPair } from '../rsa'; +import { decryptForGroup, rekeyGroupAndEncrypt } from '../group'; + +test('rekeying', async () => { + // --- ENCRYPTION FOR A NEW KEY --- + + // --- EXAMPLE USAGE --- + // --- Step 1: Initialize group with Alice and Bob --- + const aliceKeyPair = await generateRsaOaepKeyPair(); + const bobKeyPair = await generateRsaOaepKeyPair(); + let recipientPublicKeys = [aliceKeyPair.publicKey, bobKeyPair.publicKey]; + + // --- Step 2: Encrypt initial message --- + const initialMessage = 'Hello everyone! The group chat is open.'; + let encryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, initialMessage); + console.log(`\nAlice and Bob successfully receive: "${await decryptForGroup(encryptedPackage, aliceKeyPair.privateKey, 0)}"`); + + // --- Step 3: Add new user, Charlie --- + const charlieKeyPair = await generateRsaOaepKeyPair(); + recipientPublicKeys.push(charlieKeyPair.publicKey); + + // --- Step 4: Rekey the group with a new message for Charlie --- + const secondMessage = 'Welcome Charlie! This is a new message only you and other members can see.'; + let newEncryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, secondMessage); + + // --- Step 5: Verify decryption for all members --- + const decryptedByAlice = await decryptForGroup(newEncryptedPackage, aliceKeyPair.privateKey, 0); + const decryptedByBob = await decryptForGroup(newEncryptedPackage, bobKeyPair.privateKey, 1); + const decryptedByCharlie = await decryptForGroup(newEncryptedPackage, charlieKeyPair.privateKey, 2); + + expect(decryptedByCharlie).toBe(secondMessage); + expect(decryptedByAlice).toBe(secondMessage); + expect(decryptedByBob).toBe(secondMessage); + + const oldMessageDecryptedByAlice = await decryptForGroup(encryptedPackage, aliceKeyPair.privateKey, 0); + const oldMessageDecryptedByBob = await decryptForGroup(encryptedPackage, bobKeyPair.privateKey, 1); + const oldMessageDecryptedByCharlie = await decryptForGroup(encryptedPackage, charlieKeyPair.privateKey, 2); + + expect(oldMessageDecryptedByAlice).toBe(initialMessage); + expect(oldMessageDecryptedByBob).toBe(initialMessage); + expect(oldMessageDecryptedByCharlie).toBe(initialMessage); +}); diff --git a/packages/e2ee/src/__tests__/pbkdf2.spec.ts b/packages/e2ee/src/__tests__/pbkdf2.spec.ts index a19f12b3228ca..5338107d23e64 100644 --- a/packages/e2ee/src/__tests__/pbkdf2.spec.ts +++ b/packages/e2ee/src/__tests__/pbkdf2.spec.ts @@ -4,5 +4,5 @@ import { derivePbkdf2Key, importPbkdf2Key } from '../pbkdf2.ts'; test('pbkdf2', async () => { const baseKey = await importPbkdf2Key('passphrase'); const derived = await derivePbkdf2Key('salt', baseKey); - expect(derived.algorithm.name).toBe('AES-CBC'); + expect(derived.algorithm.name).toBe('AES-GCM'); }); diff --git a/packages/e2ee/src/__tests__/rsa.spec.ts b/packages/e2ee/src/__tests__/rsa.spec.ts index 0b00152ba99c3..b6836c92bdfce 100644 --- a/packages/e2ee/src/__tests__/rsa.spec.ts +++ b/packages/e2ee/src/__tests__/rsa.spec.ts @@ -5,8 +5,7 @@ const encode = (s: string) => new TextEncoder().encode(s); const decode = (b: ArrayBuffer | Uint8Array) => new TextDecoder().decode(b instanceof Uint8Array ? b : new Uint8Array(b)); test('generate, encrypt with public key, and decrypt with private key', async () => { - const { rsa } = await generateRsaOaepKeyPair(); - const { publicKey, privateKey } = rsa; + const { publicKey, privateKey } = await generateRsaOaepKeyPair(); const plaintext = 'Hello RSA-OAEP'; const data = encode(plaintext); @@ -21,8 +20,7 @@ test('generate, encrypt with public key, and decrypt with private key', async () }, 20000); test('export private key to JWK, import back, imported key is non-extractable and usable', async () => { - const { rsa } = await generateRsaOaepKeyPair(); - const { publicKey, privateKey } = rsa; + const { publicKey, privateKey } = await generateRsaOaepKeyPair(); const jwk = await exportRsaOaepKey(privateKey); expect(jwk.kty).toBe('RSA'); @@ -41,8 +39,7 @@ test('export private key to JWK, import back, imported key is non-extractable an }, 20000); test('importRsaOaepKey rejects when given a public JWK for decrypt usage', async () => { - const { rsa } = await generateRsaOaepKeyPair(); - const { publicKey } = rsa; + const { publicKey } = await generateRsaOaepKeyPair(); const publicJwk = await exportRsaOaepKey(publicKey); expect(publicJwk.kty).toBe('RSA'); @@ -52,8 +49,7 @@ test('importRsaOaepKey rejects when given a public JWK for decrypt usage', async }, 10000); test('encrypt with private key rejects; decrypt with public key rejects', async () => { - const { rsa } = await generateRsaOaepKeyPair(); - const { publicKey, privateKey } = rsa; + const { publicKey, privateKey } = await generateRsaOaepKeyPair(); const data = encode('invalid ops'); @@ -64,8 +60,7 @@ test('encrypt with private key rejects; decrypt with public key rejects', async }, 15000); test('tampering with ciphertext causes decryption to fail', async () => { - const { rsa } = await generateRsaOaepKeyPair(); - const { publicKey, privateKey } = rsa; + const { publicKey, privateKey } = await generateRsaOaepKeyPair(); const data = encode('Do not tamper'); diff --git a/packages/e2ee/src/group.ts b/packages/e2ee/src/group.ts new file mode 100644 index 0000000000000..6cf941769850b --- /dev/null +++ b/packages/e2ee/src/group.ts @@ -0,0 +1,100 @@ +function encode(str: string) { + return new TextEncoder().encode(str); +} + +// A helper function to decode a Uint8Array to a string +function decode(buf: ArrayBuffer) { + return new TextDecoder().decode(buf); +} + +export interface EncryptedPackage { + ciphertext: ArrayBuffer; + wrappedAesKeys: ArrayBuffer[]; + iv: Uint8Array; +} +export interface EncryptedData { + ciphertext: ArrayBuffer; + iv: Uint8Array; +} + +export async function decryptMessage( + encryptedData: EncryptedData, + recipientPrivateKey: CryptoKey, + recipientWrappedKey: ArrayBuffer, +): Promise { + const unwrappedAesKey = await crypto.subtle.unwrapKey( + 'raw', + recipientWrappedKey, + recipientPrivateKey, + { name: 'RSA-OAEP' }, + { name: 'AES-GCM', length: 256 }, + true, + ['decrypt'], + ); + const decryptedText = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: encryptedData.iv }, unwrappedAesKey, encryptedData.ciphertext); + return new TextDecoder().decode(decryptedText); +} + +/** + * Creates a new symmetric key and rekeys for all current group members and a new one. + * @param recipientPublicKeys Array of public keys for all recipients (old and new). + * @param plaintext Optional message to send with the key update. + * @returns Encrypted package with a new symmetric key for the group. + */ +export async function rekeyGroupAndEncrypt( + recipientPublicKeys: Array, + plaintext: string = 'New member joined.', +): Promise { + // 1. Generate a brand new, one-time AES-GCM key for this message and all future messages + const newAesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + + // 2. Generate a random Initialization Vector (IV) for AES + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // 3. Encrypt the plaintext message with the new AES key + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, newAesKey, encode(plaintext)); + + // 4. Wrap the new AES key with each recipient's public key + const wrappedAesKeys = await Promise.all( + recipientPublicKeys.map((key) => crypto.subtle.wrapKey('raw', newAesKey, key, { name: 'RSA-OAEP' })), + ); + + // 5. Combine all parts into a single package + return { + ciphertext, + wrappedAesKeys, + iv, + }; +} + +// --- DECRYPTION (Recipient's side) --- + +/** + * Decrypts a group message using a recipient's private key. + * @param encryptedData The encrypted package. + * @param recipientPrivateKey The recipient's private key. + * @param recipientIndex The recipient's index in the group. + * @returns The decrypted plaintext. + */ +export async function decryptForGroup( + encryptedData: { wrappedAesKeys: ArrayBuffer[]; iv: Uint8Array; ciphertext: ArrayBuffer }, + recipientPrivateKey: CryptoKey, + recipientIndex: number, +): Promise { + // 1. Unwrap the specific AES key intended for this recipient + const wrappedAesKey = encryptedData.wrappedAesKeys[recipientIndex]!; + const unwrappedAesKey = await crypto.subtle.unwrapKey( + 'raw', + wrappedAesKey, + recipientPrivateKey, + { name: 'RSA-OAEP' }, + { name: 'AES-GCM', length: 256 }, + true, + ['decrypt'], + ); + + // 2. Decrypt the message ciphertext with the unwrapped AES key + const decryptedText = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: encryptedData.iv }, unwrappedAesKey, encryptedData.ciphertext); + + return decode(decryptedText); +} diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index bdb9684fd8434..2fb33d6278d4e 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -8,7 +8,7 @@ import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; -import { decryptAesCbc, encryptAesCbc } from './aes.ts'; +import { decryptAesCbc, encryptAesCbc, encryptAesGcm, decryptAesGcm } from './aes.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); @@ -70,7 +70,7 @@ export default class E2EE { async createAndLoadKeys(): AsyncResult { try { - const { rsa: keys } = await generateRsaOaepKeyPair(); + const keys = await generateRsaOaepKeyPair(); try { const publicKey = await this.setPublicKey(keys.publicKey); try { @@ -120,7 +120,7 @@ export default class E2EE { const vector = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); try { const privateKeyBuffer = toArrayBuffer(privateKey); - const encryptedPrivateKey = await encryptAesCbc(vector, masterKey.value, privateKeyBuffer); + const encryptedPrivateKey = await encryptAesGcm(vector, masterKey.value, privateKeyBuffer); const joined = joinVectorAndEncryptedData(vector, encryptedPrivateKey); const stringified = stringifyUint8Array(joined); return ok(stringified); @@ -138,7 +138,7 @@ export default class E2EE { const [vector, cipherText] = splitVectorAndEncryptedData(parseUint8Array(privateKey)); try { - const privKey = await decryptAesCbc(vector, masterKey.value, cipherText); + const privKey = await decryptAesGcm(vector, masterKey.value, cipherText); return ok(privKey); } catch (error) { return err(new Error('Error decrypting private key', { cause: error })); diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts index f0fe85731b378..fcd24d7b203e9 100644 --- a/packages/e2ee/src/pbkdf2.ts +++ b/packages/e2ee/src/pbkdf2.ts @@ -20,7 +20,7 @@ export const derivePbkdf2Key = async (salt: string, baseKey: CryptoKey): Promise }, baseKey, { - name: 'AES-CBC', + name: 'AES-GCM', length: 256, }, false, diff --git a/packages/e2ee/src/rsa.ts b/packages/e2ee/src/rsa.ts index cdf50cc1d8221..1c6fdd1f99891 100644 --- a/packages/e2ee/src/rsa.ts +++ b/packages/e2ee/src/rsa.ts @@ -1,14 +1,10 @@ -export interface RsaKeyPair { - rsa: CryptoKeyPair; -} - -export const generateRsaOaepKeyPair = async (): Promise => { +export const generateRsaOaepKeyPair = async (): Promise => { const keyPair = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, - ['encrypt', 'decrypt'], + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], ); - return { rsa: keyPair }; + return keyPair; }; export const importRsaOaepKey = async (jwk: JsonWebKey): Promise => { From 7d0a0b3ee536fcc1bd21c3ef848bd1ca846a09a5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 17:47:11 -0300 Subject: [PATCH 082/251] add keychain --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- packages/e2ee/.oxlintrc.json | 7 +- packages/e2ee/package.json | 7 +- packages/e2ee/src/__tests__/backfill.spec.ts | 85 ----- packages/e2ee/src/__tests__/e2ee.spec.ts | 60 --- packages/e2ee/src/__tests__/group.spec.ts | 173 ++++++--- packages/e2ee/src/__tests__/helpers/db.ts | 45 --- packages/e2ee/src/__tests__/mock/server.ts | 126 +++++++ packages/e2ee/src/crypto.ts | 190 ++++++++++ packages/e2ee/src/group.ts | 348 +++++++++++++----- packages/e2ee/src/index.ts | 6 +- packages/e2ee/src/keychain.ts | 73 ++++ packages/e2ee/vitest.config.ts | 10 +- yarn.lock | 205 ++++++----- 14 files changed, 923 insertions(+), 414 deletions(-) delete mode 100644 packages/e2ee/src/__tests__/backfill.spec.ts delete mode 100644 packages/e2ee/src/__tests__/e2ee.spec.ts delete mode 100644 packages/e2ee/src/__tests__/helpers/db.ts create mode 100644 packages/e2ee/src/__tests__/mock/server.ts create mode 100644 packages/e2ee/src/crypto.ts create mode 100644 packages/e2ee/src/keychain.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index b985187c6793f..559048ec96561 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -68,7 +68,7 @@ class E2E extends Emitter<{ this.instancesByRoomId = {}; this.keyDistributionInterval = null; this.e2ee = new E2EE({ - userId: () => Promise.resolve(Meteor.userId()), + userId: () => Meteor.userId(), fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), persistKeys: (keys, force) => sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index e22b85329f18a..e91e398aebf87 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -20,6 +20,11 @@ "eslint/no-magic-numbers": "allow", "eslint/no-ternary": "allow", "unicorn/prefer-code-point": "allow", - "eslint/max-lines": "allow" + "unicorn/prefer-add-event-listener": "allow", + "eslint/max-lines": "allow", + "eslint/max-classes-per-file": "allow", + "no-extraneous-class": "allow", + "no-console": "allow", + "no-rest-spread-properties": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 710ef46686e63..2033bf68f2f44 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -24,13 +24,14 @@ "test": "vitest run" }, "devDependencies": { - "@vitest/browser": "4.0.0-beta.9", - "@vitest/coverage-v8": "4.0.0-beta.9", + "@vitest/browser": "4.0.0-beta.10", + "@vitest/coverage-v8": "4.0.0-beta.10", + "@vitest/ui": "4.0.0-beta.10", "oxlint": "~1.14.0", "oxlint-tsgolint": "~0.1.5", "playwright": "^1.55.0", "typescript": "~5.9.2", - "vitest": "4.0.0-beta.9" + "vitest": "4.0.0-beta.10" }, "license": "MIT", "publishConfig": { diff --git a/packages/e2ee/src/__tests__/backfill.spec.ts b/packages/e2ee/src/__tests__/backfill.spec.ts deleted file mode 100644 index 7303656aea89c..0000000000000 --- a/packages/e2ee/src/__tests__/backfill.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { expect, test } from 'vitest'; -import { decryptForGroup, decryptMessage, rekeyGroupAndEncrypt, type EncryptedData } from '../group'; -import { generateRsaOaepKeyPair } from '../rsa'; - -type SymmetricKey = { key: ArrayBuffer; messageIndex: number }; - -// In a real application, these would be stored encrypted in a database. -const serverStorage = { - messages: [] as EncryptedData[], - symmetricKeys: [] as SymmetricKey[], -}; - -// --- ENCRYPTION AND STORAGE (Simulated server-side) --- -async function encryptAndStore(plaintext: string, recipientPublicKeys: CryptoKey[]) { - // 1. Generate new AES key and IV - const aesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - // 2. Encrypt the plaintext message - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, aesKey, new TextEncoder().encode(plaintext)); - - // 3. Store the encrypted message and IV on the server - serverStorage.messages.push({ ciphertext, iv }); - - // 4. Extract and store the raw symmetric key on the server (encrypted) - const rawAesKey = await crypto.subtle.exportKey('raw', aesKey); - serverStorage.symmetricKeys.push({ key: rawAesKey, messageIndex: serverStorage.messages.length - 1 }); - - // 5. Wrap the raw key for each recipient and distribute - const wrappedKeys = await Promise.all(recipientPublicKeys.map((key) => crypto.subtle.wrapKey('raw', aesKey, key, { name: 'RSA-OAEP' }))); - return { ciphertext, wrappedKeys, iv }; -} - -// --- BACKFILLING FOR A NEW USER (Simulated on server) --- -async function createBackfillPackage(newRecipientPublicKey: CryptoKey) { - const wrappedSymmetricKeys = await Promise.all( - serverStorage.symmetricKeys.map(async (keyObject) => { - const keyToWrap = await crypto.subtle.importKey('raw', keyObject.key, 'AES-GCM', true, ['wrapKey']); - const wrappedKey = await crypto.subtle.wrapKey('raw', keyToWrap, newRecipientPublicKey, { name: 'RSA-OAEP' }); - return { wrappedKey, messageIndex: keyObject.messageIndex }; - }), - ); - - return { - messages: serverStorage.messages, - wrappedSymmetricKeys: wrappedSymmetricKeys, - }; -} - -test('backfilling', async () => { - // --- Step 1: Initialize group with Alice and Bob --- - const aliceKeyPair = await generateRsaOaepKeyPair(); - const bobKeyPair = await generateRsaOaepKeyPair(); - let groupMembers = [aliceKeyPair, bobKeyPair]; - let recipientPublicKeys = groupMembers.map((m) => m.publicKey); - - // --- Step 2: Simulate initial messages from Alice and Bob --- - await encryptAndStore('Hello from Alice!', recipientPublicKeys); - await encryptAndStore("Hey Alice, how's it going?", recipientPublicKeys); - - // --- Step 3: Add new user, Charlie --- - const charlieKeyPair = await generateRsaOaepKeyPair(); - groupMembers.push(charlieKeyPair); - recipientPublicKeys = groupMembers.map((m) => m.publicKey); - - // --- Step 4: Create backfill package for Charlie --- - const charlieBackfillPackage = await createBackfillPackage(charlieKeyPair.publicKey); - - // --- Step 5: Charlie decrypts all messages --- - const decryptedMessages: string[] = []; - for (const wrappedKeyInfo of charlieBackfillPackage.wrappedSymmetricKeys) { - const messageIndex = wrappedKeyInfo.messageIndex; - const messageData = charlieBackfillPackage.messages[messageIndex]!; - const decryptedMessage = await decryptMessage(messageData, charlieKeyPair.privateKey, wrappedKeyInfo.wrappedKey); - decryptedMessages.push(decryptedMessage); - } - - expect(decryptedMessages).toEqual(['Hello from Alice!', "Hey Alice, how's it going?"]); - - // --- Step 6: Send new message with all members --- - const newMessage = 'Welcome Charlie! This is a new message.'; - const newEncryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, newMessage); - const decryptedByCharlie = await decryptForGroup(newEncryptedPackage, charlieKeyPair.privateKey, 2); - expect(decryptedByCharlie).toBe(newMessage); -}); diff --git a/packages/e2ee/src/__tests__/e2ee.spec.ts b/packages/e2ee/src/__tests__/e2ee.spec.ts deleted file mode 100644 index 7779d0a3ff515..0000000000000 --- a/packages/e2ee/src/__tests__/e2ee.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from 'vitest'; -import E2EE from '../index.ts'; -import { createDb } from './helpers/db.ts'; -import type { Result } from '../result.ts'; - -const db = createDb(); -export const unwrap = (result: Result): V => { - if (result.isOk) { - return result.value; - } - throw result; -}; - -const bob = new E2EE({ - fetchMyKeys: async () => { - return await db.get('bob'); - }, - userId: async () => 'bob', - persistKeys: (keys, force) => { - return db.set('bob', keys, force); - }, -}); - -test('load user keys', async () => { - const keys = unwrap(await bob.createAndLoadKeys()); - expect(keys).toHaveProperty('publicKey'); - expect(keys).toHaveProperty('privateKey'); - const password = await bob.createRandomPassword(5); - expect(password.split(' ').length).toBe(5); - expect(bob.getRandomPassword()).toBe(password); - bob.removeRandomPassword(); - expect(bob.getRandomPassword()).toBeFalsy(); - const localKeys = bob.getKeysFromLocalStorage(); - expect(localKeys.private_key).toBeDefined(); - expect(localKeys.public_key).toBeDefined(); - unwrap(await bob.persistKeys(localKeys, password, true)); - - // Recovery - const recoveredKeys = unwrap(await bob.loadKeysFromDB()); - expect(recoveredKeys).toHaveProperty('public_key'); - expect(recoveredKeys).toHaveProperty('private_key'); - - // Wrong password - expect(await bob.decodePrivateKey(recoveredKeys.private_key, 'wrong password')).toHaveProperty('error'); - - // Load keys - const decodedKey = unwrap(await bob.decodePrivateKey(recoveredKeys.private_key, password)); - const privateKey = unwrap( - await bob.loadKeys({ - private_key: decodedKey, - public_key: recoveredKeys.public_key, - }), - ); - - expect(privateKey).toBeInstanceOf(CryptoKey); - - // Remove keys from local storage - bob.removeKeysFromLocalStorage(); - expect(bob.getKeysFromLocalStorage()).toMatchObject({ private_key: null, public_key: null }); -}); diff --git a/packages/e2ee/src/__tests__/group.spec.ts b/packages/e2ee/src/__tests__/group.spec.ts index 6c06e050b9ef8..b91d4f8cce76e 100644 --- a/packages/e2ee/src/__tests__/group.spec.ts +++ b/packages/e2ee/src/__tests__/group.spec.ts @@ -1,43 +1,132 @@ -import { test, expect } from 'vitest'; -import { generateRsaOaepKeyPair } from '../rsa'; -import { decryptForGroup, rekeyGroupAndEncrypt } from '../group'; - -test('rekeying', async () => { - // --- ENCRYPTION FOR A NEW KEY --- - - // --- EXAMPLE USAGE --- - // --- Step 1: Initialize group with Alice and Bob --- - const aliceKeyPair = await generateRsaOaepKeyPair(); - const bobKeyPair = await generateRsaOaepKeyPair(); - let recipientPublicKeys = [aliceKeyPair.publicKey, bobKeyPair.publicKey]; - - // --- Step 2: Encrypt initial message --- - const initialMessage = 'Hello everyone! The group chat is open.'; - let encryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, initialMessage); - console.log(`\nAlice and Bob successfully receive: "${await decryptForGroup(encryptedPackage, aliceKeyPair.privateKey, 0)}"`); - - // --- Step 3: Add new user, Charlie --- - const charlieKeyPair = await generateRsaOaepKeyPair(); - recipientPublicKeys.push(charlieKeyPair.publicKey); - - // --- Step 4: Rekey the group with a new message for Charlie --- - const secondMessage = 'Welcome Charlie! This is a new message only you and other members can see.'; - let newEncryptedPackage = await rekeyGroupAndEncrypt(recipientPublicKeys, secondMessage); - - // --- Step 5: Verify decryption for all members --- - const decryptedByAlice = await decryptForGroup(newEncryptedPackage, aliceKeyPair.privateKey, 0); - const decryptedByBob = await decryptForGroup(newEncryptedPackage, bobKeyPair.privateKey, 1); - const decryptedByCharlie = await decryptForGroup(newEncryptedPackage, charlieKeyPair.privateKey, 2); - - expect(decryptedByCharlie).toBe(secondMessage); - expect(decryptedByAlice).toBe(secondMessage); - expect(decryptedByBob).toBe(secondMessage); - - const oldMessageDecryptedByAlice = await decryptForGroup(encryptedPackage, aliceKeyPair.privateKey, 0); - const oldMessageDecryptedByBob = await decryptForGroup(encryptedPackage, bobKeyPair.privateKey, 1); - const oldMessageDecryptedByCharlie = await decryptForGroup(encryptedPackage, charlieKeyPair.privateKey, 2); - - expect(oldMessageDecryptedByAlice).toBe(initialMessage); - expect(oldMessageDecryptedByBob).toBe(initialMessage); - expect(oldMessageDecryptedByCharlie).toBe(initialMessage); +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EncryptionClient } from '../group'; +import { MockChatService } from './mock/server'; + +describe('EncryptionClient', () => { + let mockService: MockChatService; + + beforeEach(() => { + // Reset the mock service and IndexedDB before each test + mockService = new MockChatService(); + indexedDB.deleteDatabase('e2ee-chat-db'); + }); + + describe('User Management', () => { + it('should register a new user and store their data via the service', async () => { + const user = new EncryptionClient(mockService); + const userId = 'alice@example.com'; + const password = 'password123'; + + const spy = vi.spyOn(mockService, 'registerUser'); + + await user.initialize(userId); + await user.register(password); + + // Check that the library called the service correctly + expect(spy).toHaveBeenCalledOnce(); + const userData = await mockService.getUserData(userId); + expect(userData).toBeDefined(); + expect(userData?.publicKeyInfo.format).toBe('jwk'); + }); + + it('should log in a user with a passphrase if key is not local', async () => { + const registrationService = new MockChatService(); + const registrant = new EncryptionClient(registrationService); + const userId = 'bob@example.com'; + const password = 'password-bob'; + + // 1. Bob registers on one device, populating the service. + await registrant.initialize(userId); + await registrant.register(password); + + // 2. Bob tries to log in on a *new device* (simulated by a new instance and empty IndexedDB). + const loginUser = new EncryptionClient(registrationService); + await loginUser.initialize(userId); + await loginUser.login(password); + + // @ts-expect-error - Accessing private property for test verification + expect(loginUser.userId).toBe(userId); + // @ts-expect-error + expect(loginUser.privateKey).not.toBeNull(); + }); + + it('should permanently delete the account from the device storage', async () => { + const user = new EncryptionClient(mockService); + const userId = 'frank@example.com'; + await user.initialize(userId); + await user.register('password-frank'); + + await user.deleteAccountFromDevice(); + + // Verify in-memory session is cleared + // @ts-expect-error + expect(user.userId).toBe(false); + + // Verify login fails without a passphrase, proving the key is gone + const userInNewSession = new EncryptionClient(mockService); + await userInNewSession.initialize(userId); + await expect(userInNewSession.login()).rejects.toThrow('Private key not found locally. Passphrase is required for recovery.'); + }); + }); + + describe('Full Group Chat End-to-End Flow', () => { + it('should allow users to create groups, send messages, and for new users to decrypt history', async () => { + // --- SETUP --- + // 1. Alice, Bob, and Carol register with the same chat service. + const alice = new EncryptionClient(mockService); + const bob = new EncryptionClient(mockService); + const carol = new EncryptionClient(mockService); + + await alice.initialize('alice'); + await bob.initialize('bob'); + await carol.initialize('carol'); + + await alice.register('alice-pass'); + await bob.register('bob-pass'); + await carol.register('carol-pass'); + + const groupId = 'tech-enthusiasts'; + + // --- GROUP CREATION --- + // 2. Alice creates a group and invites Bob. + await alice.createGroup(groupId, ['bob']); + + // --- SEND & RECEIVE --- + // 3. Alice sends some messages to the group. + await alice.sendMessage(groupId, 'Hello Bob!'); + await alice.sendMessage(groupId, 'How is the project going?'); + + // 4. Bob logs in on his device and joins the group. + // In a real app, Bob would get a notification. Here we simulate it directly. + await bob.joinGroup(groupId); + + // 5. Bob fetches the messages and decrypts them. + const bobsMessages = await bob.getMessages(groupId); + expect(bobsMessages).toEqual(['Hello Bob!', 'How is the project going?']); + + // --- ADDING A NEW USER --- + // 6. Alice decides to add Carol to the group. + await alice.addUserToGroup(groupId, 'carol'); + + // 7. Carol joins the group. + await carol.joinGroup(groupId); + + // 8. Carol fetches the *entire message history* and decrypts it. + // This confirms a core requirement: new users have access to past messages. + const carolsMessages = await carol.getMessages(groupId); + expect(carolsMessages).toEqual(['Hello Bob!', 'How is the project going?']); + + // 9. Bob sends a new message. + await bob.sendMessage(groupId, 'Hi Alice and Carol! Project is going great.'); + + // 10. Alice and Carol fetch the latest messages. + const alicesFinalMessages = await alice.getMessages(groupId); + const carolsFinalMessages = await carol.getMessages(groupId); + + const expectedFinalHistory = ['Hello Bob!', 'How is the project going?', 'Hi Alice and Carol! Project is going great.']; + + expect(alicesFinalMessages).toEqual(expectedFinalHistory); + expect(carolsFinalMessages).toEqual(expectedFinalHistory); + }); + }); }); diff --git a/packages/e2ee/src/__tests__/helpers/db.ts b/packages/e2ee/src/__tests__/helpers/db.ts deleted file mode 100644 index f45230cddb8dd..0000000000000 --- a/packages/e2ee/src/__tests__/helpers/db.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { RemoteKeyPair } from '../..'; - -class Db { - data: Map; - constructor() { - this.data = new Map(); - } - - async get(userId: string): Promise { - return await new Promise((resolve, reject) => { - const data = this.data.get(userId); - setTimeout(() => { - data ? resolve(data) : reject(new Error('User not found')); - }, 100); - }); - } - - async set(userId: string, data: Data, force: boolean): Promise { - await new Promise((resolve, reject) => { - setTimeout(() => { - if (this.data.has(userId)) { - if (!force) { - reject(new Error('User already exists')); - } else { - this.data.set(userId, data); - resolve(); - } - } else { - this.data.set(userId, data); - resolve(); - } - }, 100); - }); - } - - async update(userId: string, update: Partial): Promise { - const user = await this.get(userId); - if (!user) { - throw new Error('User not found'); - } - this.data.set(userId, { ...user, ...update }); - } -} - -export const createDb = (): Db => new Db(); diff --git a/packages/e2ee/src/__tests__/mock/server.ts b/packages/e2ee/src/__tests__/mock/server.ts new file mode 100644 index 0000000000000..3ff96ba6874a2 --- /dev/null +++ b/packages/e2ee/src/__tests__/mock/server.ts @@ -0,0 +1,126 @@ +import type { EncryptedMessage, EncryptedPrivateKeyBundle, PublicKeyInfo } from '../../crypto'; +import type { EncryptedGroupKeyInfo, ChatService } from '../../group'; + +/** + * A mock backend chat service for testing purposes. + * This class simulates a server's database in memory to manage users, + * groups, and messages, allowing for end-to-end testing of the crypto library. + */ +export class MockChatService implements ChatService { + // Simulates a 'users' table in a database + private users = new Map< + string, + { + publicKeyInfo: PublicKeyInfo; + privateKeyBundle: EncryptedPrivateKeyBundle; + } + >(); + + // Simulates a 'groups' table + private groups = new Map< + string, + { + members: Set; + encryptedGroupKeys: Map; + } + >(); + + // Simulates a message store for each group + private messages = new Map(); + + /** + * Simulates registering a user by storing their public key and encrypted private key bundle. + */ + public async registerUser(userId: string, publicKeyInfo: PublicKeyInfo, privateKeyBundle: EncryptedPrivateKeyBundle): Promise { + if (this.users.has(userId)) { + throw new Error(`User ${userId} already exists.`); + } + this.users.set(userId, { publicKeyInfo, privateKeyBundle }); + } + + /** + * Simulates fetching user data required for login or recovery. + */ + public async getUserData(userId: string): Promise< + | { + publicKeyInfo: PublicKeyInfo; + privateKeyBundle: EncryptedPrivateKeyBundle; + } + | undefined + > { + return this.users.get(userId); + } + + /** + * Simulates fetching multiple users' public keys. + */ + public async findUsersPublicKeys(userIds: string[]): Promise> { + const result = new Map(); + for (const userId of userIds) { + result.set(userId, await this.findUserPublicKey(userId)); + } + return result; + } + + /** + * Simulates fetching a user's public key, which is needed to add them to a group. + */ + public async findUserPublicKey(userId: string): Promise { + return this.users.get(userId)?.publicKeyInfo; + } + + /** + * Simulates creating a new group chat. + */ + public async createGroup(groupId: string, initialEncryptedKeys: EncryptedGroupKeyInfo[]): Promise { + if (this.groups.has(groupId)) { + throw new Error(`Group ${groupId} already exists.`); + } + + const members = new Set(); + const encryptedGroupKeys = new Map(); + + for (const keyInfo of initialEncryptedKeys) { + members.add(keyInfo.userId); + encryptedGroupKeys.set(keyInfo.userId, keyInfo); + } + + this.groups.set(groupId, { members, encryptedGroupKeys }); + } + + /** + * Simulates adding a new user to an existing group. + */ + public async addUserToGroup(groupId: string, newMemberKeyInfo: EncryptedGroupKeyInfo): Promise { + const group = this.groups.get(groupId); + if (!group) { + throw new Error(`Group ${groupId} not found.`); + } + group.members.add(newMemberKeyInfo.userId); + group.encryptedGroupKeys.set(newMemberKeyInfo.userId, newMemberKeyInfo); + } + + /** + * Simulates a user fetching their specific encrypted group key to join a group. + */ + public async getGroupKeyForUser(groupId: string, userId: string): Promise { + return this.groups.get(groupId)?.encryptedGroupKeys.get(userId); + } + + /** + * Simulates posting an encrypted message to a group's message log. + */ + public async postMessage(groupId: string, message: EncryptedMessage): Promise { + if (!this.messages.has(groupId)) { + this.messages.set(groupId, []); + } + this.messages.get(groupId)?.push(message); + } + + /** + * Simulates fetching the entire message history for a group. + */ + public async getMessages(groupId: string): Promise { + return this.messages.get(groupId) || []; + } +} diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/crypto.ts new file mode 100644 index 0000000000000..17860ac353374 --- /dev/null +++ b/packages/e2ee/src/crypto.ts @@ -0,0 +1,190 @@ +const RSA_ALGORITHM = { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', +} as const satisfies RsaHashedKeyGenParams; + +const AES_ALGORITHM = { name: 'AES-GCM', length: 256 } as const satisfies AesKeyGenParams; + +const PBKDF2_PARAMS = { + name: 'PBKDF2', + hash: 'SHA-256', + iterations: 100_000, +} as const satisfies Omit; + +/** + * Encodes an ArrayBuffer into a Base64 string. + */ +const arrayBufferToBase64 = (buffer: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buffer))); + +/** + * Decodes a Base64 string into an ArrayBuffer. + */ +const base64ToArrayBuffer = (base64: string): Uint8Array => { + const binary_string = atob(base64); + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; +}; + +/** + * Generates a new RSA-OAEP key pair for asymmetric encryption. + */ +export const rsaGenerateKeyPair = async (): Promise => { + const keyPair = await crypto.subtle.generateKey(RSA_ALGORITHM, true, ['encrypt', 'decrypt']); + return keyPair; +}; + +/** + * Generates a new AES-GCM key for symmetric encryption. + */ +export const generateAesKey = async (): Promise => { + const key = await crypto.subtle.generateKey(AES_ALGORITHM, true, ['encrypt', 'decrypt']); + return key; +}; + +/** + * Derives an AES-GCM key from a passphrase using PBKDF2. + * @param passphrase The user's secret passphrase. + * @param salt A unique salt for the key derivation. + */ +export const deriveKeyFromPassphrase = async (passphrase: string, salt: Uint8Array): Promise => { + const masterKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); + + const key = await crypto.subtle.deriveKey({ ...PBKDF2_PARAMS, salt }, masterKey, { name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); + + return key; +}; + +/** + * A bundle containing the user's encrypted private key and the necessary metadata for decryption. + * This should be stored securely on a server for account recovery. + */ +export interface EncryptedPrivateKeyBundle { + salt: string; // Base64 + iv: string; // Base64 + ciphertext: string; // Base64 + pbkdf2Iterations: number; + hashAlgorithm: 'SHA-256'; + encryptionAlgorithm: 'AES-GCM'; +} + +/** + * Decrypts a user's private key using their passphrase. + * @param bundle The bundle containing the encrypted key and metadata. + * @param passphrase The user's passphrase. + */ +export const recoverPrivateKeyFromBundle = async (bundle: EncryptedPrivateKeyBundle, passphrase: string): Promise => { + const salt = base64ToArrayBuffer(bundle.salt); + const iv = base64ToArrayBuffer(bundle.iv); + const ciphertext = base64ToArrayBuffer(bundle.ciphertext); + + const derivedKey = await deriveKeyFromPassphrase(passphrase, salt); + const decryptedKeyData = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derivedKey, ciphertext); + + const privateKey = await crypto.subtle.importKey('pkcs8', decryptedKeyData, RSA_ALGORITHM, true, ['decrypt']); + + return privateKey; +}; + +/** + * Encrypts data with an RSA public key. + * Used for wrapping the group's symmetric key. + */ +export const rsaEncrypt = async (groupKey: CryptoKey, publicKey: CryptoKey): Promise => { + const rawKey = await crypto.subtle.exportKey('raw', groupKey); + const encrypted = await crypto.subtle.encrypt(RSA_ALGORITHM, publicKey, rawKey); + return arrayBufferToBase64(encrypted); +}; + +/** + * Decrypts data with an RSA private key. + * Used for unwrapping the group's symmetric key. + */ +export const rsaDecrypt = async (encryptedKeyBase64: string, privateKey: CryptoKey): Promise => { + const encryptedKey = base64ToArrayBuffer(encryptedKeyBase64); + const decryptedRawKey = await crypto.subtle.decrypt(RSA_ALGORITHM, privateKey, encryptedKey); + const importedKey = await crypto.subtle.importKey('raw', decryptedRawKey, AES_ALGORITHM, true, ['encrypt', 'decrypt']); + return importedKey; +}; + +/** + * Represents the structure of an encrypted message ready for transmission. + */ +export interface EncryptedMessage { + ciphertext: string; // Base64 encoded + iv: string; // Base64 encoded +} + +/** + * Encrypts a message using a symmetric AES-GCM key. + */ +export const aesEncrypt = async (plaintext: string, key: CryptoKey): Promise => { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encodedPlaintext = new TextEncoder().encode(plaintext); + + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodedPlaintext); + + return { + ciphertext: arrayBufferToBase64(ciphertext), + iv: arrayBufferToBase64(iv.buffer), + }; +}; + +/** + * Decrypts a message using a symmetric AES-GCM key. + */ +export const aesDecrypt = async (encryptedMessage: EncryptedMessage, key: CryptoKey): Promise => { + const ciphertext = base64ToArrayBuffer(encryptedMessage.ciphertext); + const iv = base64ToArrayBuffer(encryptedMessage.iv); + + const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + + return new TextDecoder().decode(decryptedBuffer); +}; + +export interface PublicKeyInfo { + format: 'jwk'; + keyData: JsonWebKey; + algorithm: RsaHashedImportParams; +} + +export const importPublicKey = async (keyInfo: PublicKeyInfo): Promise => { + const publicKey = await crypto.subtle.importKey(keyInfo.format, keyInfo.keyData, keyInfo.algorithm, true, ['encrypt']); + return publicKey; +}; + +export const exportPublicKeyInfo = async (key: CryptoKey): Promise => { + const jwk = await crypto.subtle.exportKey('jwk', key); + return { + format: 'jwk', + keyData: jwk, + algorithm: { name: 'RSA-OAEP', hash: 'SHA-256' }, + }; +}; + +export const createEncryptedPrivateKeyBundle = async (privateKey: CryptoKey, passphrase: string): Promise => { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const derivedKey = await deriveKeyFromPassphrase(passphrase, salt); + const exportedPrivateKey = await crypto.subtle.exportKey('pkcs8', privateKey); + + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, derivedKey, exportedPrivateKey); + + return { + salt: arrayBufferToBase64(salt.buffer), + iv: arrayBufferToBase64(iv.buffer), + ciphertext: arrayBufferToBase64(ciphertext), + pbkdf2Iterations: PBKDF2_PARAMS.iterations, + hashAlgorithm: PBKDF2_PARAMS.hash, + encryptionAlgorithm: 'AES-GCM', + }; +}; diff --git a/packages/e2ee/src/group.ts b/packages/e2ee/src/group.ts index 6cf941769850b..266b13813f848 100644 --- a/packages/e2ee/src/group.ts +++ b/packages/e2ee/src/group.ts @@ -1,100 +1,266 @@ -function encode(str: string) { - return new TextEncoder().encode(str); -} +import { + type EncryptedMessage, + type EncryptedPrivateKeyBundle, + type PublicKeyInfo, + aesDecrypt, + aesEncrypt, + createEncryptedPrivateKeyBundle, + exportPublicKeyInfo, + generateAesKey, + importPublicKey, + recoverPrivateKeyFromBundle, + rsaDecrypt, + rsaEncrypt, + rsaGenerateKeyPair, +} from './crypto'; +import { Keychain } from './keychain'; -// A helper function to decode a Uint8Array to a string -function decode(buf: ArrayBuffer) { - return new TextDecoder().decode(buf); +/** + * Represents an encrypted group key for a specific user. + */ +export interface EncryptedGroupKeyInfo { + userId: string; + encryptedKey: string; // Base64 + keyAlgorithm: RsaHashedImportParams; } -export interface EncryptedPackage { - ciphertext: ArrayBuffer; - wrappedAesKeys: ArrayBuffer[]; - iv: Uint8Array; -} -export interface EncryptedData { - ciphertext: ArrayBuffer; - iv: Uint8Array; +export interface ChatService { + registerUser(userId: string, publicKeyInfo: PublicKeyInfo, privateKeyBundle: EncryptedPrivateKeyBundle): Promise; + getUserData(userId: string): Promise<{ publicKeyInfo: PublicKeyInfo; privateKeyBundle: EncryptedPrivateKeyBundle } | undefined>; + findUserPublicKey(userId: string): Promise; + findUsersPublicKeys(userIds: string[]): Promise>; + createGroup(groupId: string, initialEncryptedKeys: EncryptedGroupKeyInfo[]): Promise; + addUserToGroup(groupId: string, newMemberKeyInfo: EncryptedGroupKeyInfo): Promise; + getGroupKeyForUser(groupId: string, userId: string): Promise; + postMessage(groupId: string, message: EncryptedMessage): Promise; + getMessages(groupId: string): Promise; } -export async function decryptMessage( - encryptedData: EncryptedData, - recipientPrivateKey: CryptoKey, - recipientWrappedKey: ArrayBuffer, -): Promise { - const unwrappedAesKey = await crypto.subtle.unwrapKey( - 'raw', - recipientWrappedKey, - recipientPrivateKey, - { name: 'RSA-OAEP' }, - { name: 'AES-GCM', length: 256 }, - true, - ['decrypt'], - ); - const decryptedText = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: encryptedData.iv }, unwrappedAesKey, encryptedData.ciphertext); - return new TextDecoder().decode(decryptedText); -} +export class EncryptionClient { + private userId: string | false; + private privateKey: CryptoKey | false; + private groupKeys: Map; + private keychain: Keychain; + private chatService: ChatService; -/** - * Creates a new symmetric key and rekeys for all current group members and a new one. - * @param recipientPublicKeys Array of public keys for all recipients (old and new). - * @param plaintext Optional message to send with the key update. - * @returns Encrypted package with a new symmetric key for the group. - */ -export async function rekeyGroupAndEncrypt( - recipientPublicKeys: Array, - plaintext: string = 'New member joined.', -): Promise { - // 1. Generate a brand new, one-time AES-GCM key for this message and all future messages - const newAesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - - // 2. Generate a random Initialization Vector (IV) for AES - const iv = crypto.getRandomValues(new Uint8Array(12)); - - // 3. Encrypt the plaintext message with the new AES key - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, newAesKey, encode(plaintext)); - - // 4. Wrap the new AES key with each recipient's public key - const wrappedAesKeys = await Promise.all( - recipientPublicKeys.map((key) => crypto.subtle.wrapKey('raw', newAesKey, key, { name: 'RSA-OAEP' })), - ); - - // 5. Combine all parts into a single package - return { - ciphertext, - wrappedAesKeys, - iv, - }; -} + constructor(chatService: ChatService) { + this.userId = false; + this.privateKey = false; + this.groupKeys = new Map(); + this.chatService = chatService; + this.keychain = new Keychain(); + } -// --- DECRYPTION (Recipient's side) --- + public async initialize(userId: string): Promise { + this.userId = userId; + await this.keychain.init(userId); + } -/** - * Decrypts a group message using a recipient's private key. - * @param encryptedData The encrypted package. - * @param recipientPrivateKey The recipient's private key. - * @param recipientIndex The recipient's index in the group. - * @returns The decrypted plaintext. - */ -export async function decryptForGroup( - encryptedData: { wrappedAesKeys: ArrayBuffer[]; iv: Uint8Array; ciphertext: ArrayBuffer }, - recipientPrivateKey: CryptoKey, - recipientIndex: number, -): Promise { - // 1. Unwrap the specific AES key intended for this recipient - const wrappedAesKey = encryptedData.wrappedAesKeys[recipientIndex]!; - const unwrappedAesKey = await crypto.subtle.unwrapKey( - 'raw', - wrappedAesKey, - recipientPrivateKey, - { name: 'RSA-OAEP' }, - { name: 'AES-GCM', length: 256 }, - true, - ['decrypt'], - ); - - // 2. Decrypt the message ciphertext with the unwrapped AES key - const decryptedText = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: encryptedData.iv }, unwrappedAesKey, encryptedData.ciphertext); - - return decode(decryptedText); + /** + * Registers a new user. Generates a key pair and an encrypted private key bundle for recovery. + * The public key and the encrypted bundle should be stored on a server. + * @param userId A unique identifier for the user. + * @param passphrase A secret passphrase for account recovery. + */ + public async register(passphrase: string): Promise { + if (!this.userId) { + throw new Error('User ID is not set.'); + } + const { publicKey, privateKey } = await rsaGenerateKeyPair(); + await this.keychain.savePrivateKey(privateKey); + const publicKeyInfo = await exportPublicKeyInfo(publicKey); + const privateKeyBundle = await createEncryptedPrivateKeyBundle(privateKey, passphrase); + await this.chatService.registerUser(this.userId, publicKeyInfo, privateKeyBundle); + this.privateKey = privateKey; + } + + /** + * Logs in a user. Tries to load the private key from local storage first. + * If not found, it uses the passphrase and the server-stored bundle to recover and store it. + * @param userId The user's unique identifier. + * @param passphrase The user's passphrase (only needed for recovery). + * @param privateKeyBundle The recovery bundle from the server (only needed for recovery). + */ + public async login(passphrase?: string): Promise { + let privateKey = await this.keychain.getPrivateKey(); + + if (!this.userId) { + throw new Error('User ID is not set.'); + } + + if (!privateKey) { + if (!passphrase) { + throw new Error('Private key not found locally. Passphrase is required for recovery.'); + } + const userData = await this.chatService.getUserData(this.userId); + if (!userData) { + throw new Error(`User ${this.userId} not found.`); + } + privateKey = await recoverPrivateKeyFromBundle(userData.privateKeyBundle, passphrase); + await this.keychain.savePrivateKey(privateKey); + } + this.privateKey = privateKey; + } + + /** + * Creates a new group, generates a group key, and distributes it to the initial members. + */ + public async createGroup(groupId: string, memberUserIds: string[]): Promise { + if (!this.privateKey || !this.userId) { + throw new Error('User is not logged in.'); + } + + const allMemberIds = [...new Set([this.userId, ...memberUserIds])]; + const groupKey = await generateAesKey(); + const encryptedKeyInfos: EncryptedGroupKeyInfo[] = []; + const publicKeyInfos = await this.chatService.findUsersPublicKeys(allMemberIds); + + for await (const memberId of allMemberIds) { + const publicKeyInfo = publicKeyInfos.get(memberId); + if (!publicKeyInfo) { + throw new Error(`Could not find public key for user ${memberId}`); + } + + const memberPublicKey = await importPublicKey(publicKeyInfo); + const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); + + encryptedKeyInfos.push({ + userId: memberId, + encryptedKey: encryptedGroupKey, + keyAlgorithm: publicKeyInfo.algorithm, + }); + } + + await this.chatService.createGroup(groupId, encryptedKeyInfos); + this.groupKeys.set(groupId, groupKey); + } + + /** + * Joins a group by fetching and decrypting the group key. + */ + public async joinGroup(groupId: string): Promise { + if (!this.privateKey || !this.userId) { + throw new Error('User is not logged in.'); + } + + const encryptedKeyInfo = await this.chatService.getGroupKeyForUser(groupId, this.userId); + if (!encryptedKeyInfo) { + throw new Error(`No group key found for user ${this.userId} in group ${groupId}.`); + } + + const groupKey = await rsaDecrypt(encryptedKeyInfo.encryptedKey, this.privateKey); + this.groupKeys.set(groupId, groupKey); + } + + /** + * Adds a new user to an existing group. This can only be done by a current group member. + * @param groupId The ID of the group. + * @param newUserPublicKeyInfo The public key info of the new user to add. + */ + public async addUserToGroup(groupId: string, newUserId: string): Promise { + if (!this.privateKey) { + throw new Error('User is not logged in.'); + } + const groupKey = this.groupKeys.get(groupId); + if (!groupKey) { + throw new Error(`You are not a member of group ${groupId} or key is not loaded.`); + } + const publicKeyInfo = await this.chatService.findUserPublicKey(newUserId); + if (!publicKeyInfo) { + throw new Error(`Could not find public key for user ${newUserId}`); + } + const memberPublicKey = await importPublicKey(publicKeyInfo); + const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); + await this.chatService.addUserToGroup(groupId, { + userId: newUserId, + encryptedKey: encryptedGroupKey, + keyAlgorithm: publicKeyInfo.algorithm, + }); + } + + /** + * Encrypts a message to be sent to a group. + * @param groupId The ID of the target group. + * @param plaintext The message to send. + */ + public async encryptMessage(groupId: string, plaintext: string): Promise { + const groupKey = this.groupKeys.get(groupId); + if (!groupKey) { + throw new Error(`Not a member of group ${groupId} or key not loaded.`); + } + const encryptedMessage = await aesEncrypt(plaintext, groupKey); + return encryptedMessage; + } + + /** + * Decrypts a message received from a group. + * @param groupId The ID of the source group. + * @param encryptedMessage The encrypted message object. + */ + public async decryptMessage(groupId: string, encryptedMessage: EncryptedMessage): Promise { + const groupKey = this.groupKeys.get(groupId); + if (!groupKey) { + throw new Error(`Not a member of group ${groupId} or key not loaded.`); + } + const decryptedMessage = await aesDecrypt(encryptedMessage, groupKey); + return decryptedMessage; + } + + /** + * Logs out the current user by clearing all sensitive session data from memory. + * This includes the private key and any loaded group keys. + * Note: This does NOT delete the private key from local device storage (IndexedDB) + * to allow for easier login next time. + */ + public logout(): void { + this.userId = false; + this.privateKey = false; + this.groupKeys.clear(); + } + + /** + * Performs a destructive logout, permanently deleting the user's private key + * from the local device storage (IndexedDB) and clearing all session data. + * This is intended for use on shared or untrusted devices. The user will need + * their passphrase and recovery bundle to log in on this device again. + */ + public async deleteAccountFromDevice(): Promise { + if (!this.userId) { + // If not logged in, there's nothing to delete. + console.warn('Attempted to delete account from device, but no user is logged in.'); + return; + } + + await this.keychain.deletePrivateKey(); + this.logout(); + } + + /** + * Encrypts a message and sends it to the server. + */ + public async sendMessage(groupId: string, message: string): Promise { + const groupKey = this.groupKeys.get(groupId); + if (!groupKey) { + throw new Error(`Not a member of group ${groupId} or key not loaded.`); + } + const encryptedMessage = await aesEncrypt(message, groupKey); + await this.chatService.postMessage(groupId, encryptedMessage); + } + + /** + * Fetches encrypted messages from the server and decrypts them. + */ + public async getMessages(groupId: string): Promise { + const groupKey = this.groupKeys.get(groupId); + if (!groupKey) { + throw new Error(`Not a member of group ${groupId} or key not loaded.`); + } + + const encryptedMessages = await this.chatService.getMessages(groupId); + + // Decrypt all messages in parallel + const decryptedMessages = await Promise.all(encryptedMessages.map((msg) => aesDecrypt(msg, groupKey))); + + return decryptedMessages; + } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 2fb33d6278d4e..0fb0d2cab8ffa 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -8,7 +8,7 @@ import * as Jwk from './jwk.ts'; import { stringifyUint8Array, parseUint8Array } from './base64.ts'; import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; -import { decryptAesCbc, encryptAesCbc, encryptAesGcm, decryptAesGcm } from './aes.ts'; +import { /** decryptAesCbc, encryptAesCbc, **/ encryptAesGcm, decryptAesGcm } from './aes.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); @@ -17,7 +17,7 @@ const generateMnemonicPhrase = async (length: number): Promise => { }; export interface KeyService { - userId: () => Promise; + userId: () => string; fetchMyKeys: () => Promise; persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; } @@ -47,9 +47,11 @@ export interface KeyPair { export default class E2EE { #service: KeyService; + // #client: EncryptionClient; constructor(service: KeyService) { this.#service = service; + // this.#client = new EncryptionClient(); } async loadKeys(keys: EncryptedKeyPair): AsyncResult { diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts new file mode 100644 index 0000000000000..a23ba02d94d36 --- /dev/null +++ b/packages/e2ee/src/keychain.ts @@ -0,0 +1,73 @@ +export class Keychain { + private db: { userId: string; db: IDBDatabase } | false; + + constructor() { + this.db = false; + } + + public init(userId: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(`E2eeChatDB_${userId}`, 1); + request.onerror = (): void => reject(request.error); + request.onsuccess = (): void => { + this.db = { userId, db: request.result }; + resolve(request.result); + }; + request.onupgradeneeded = (): void => { + const db = request.result; + if (!db.objectStoreNames.contains('CryptoKeys')) { + db.createObjectStore('CryptoKeys', { keyPath: 'userId' }); + } + }; + }); + } + + private async getDb(): Promise<{ userId: string; db: IDBDatabase }> { + if (!this.db) { + throw new Error('Keychain is not initialized.'); + } + return this.db; + } + + public async savePrivateKey(privateKey: CryptoKey): Promise { + const { userId, db } = await this.getDb(); + + const res = await new Promise((resolve, reject) => { + const transaction = db.transaction('CryptoKeys', 'readwrite'); + const store = transaction.objectStore('CryptoKeys'); + const request = store.put({ userId, privateKey }); + request.onsuccess = (): void => { + resolve(true); + }; + request.onerror = (): void => { + reject(new Error('Failed to save private key', { cause: request.error })); + }; + }); + + return res; + } + + public async getPrivateKey(): Promise { + const { db, userId } = await this.getDb(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('CryptoKeys', 'readonly'); + const store = transaction.objectStore('CryptoKeys'); + const request = store.get(userId); + request.onsuccess = (): void => { + resolve(request.result ? request.result.privateKey : false); + }; + request.onerror = (): void => reject(new Error('Failed to get private key', { cause: request.error })); + }); + } + + public async deletePrivateKey(): Promise { + const { db, userId } = await this.getDb(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('CryptoKeys', 'readwrite'); + const store = transaction.objectStore('CryptoKeys'); + const request = store.delete(userId); + request.onsuccess = (): void => resolve(); + request.onerror = (): void => reject(new Error('Failed to delete private key', { cause: request.error })); + }); + } +} diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index e080248b38c49..dd0c700855e86 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser/providers/playwright'; export default defineConfig({ test: { coverage: { @@ -10,9 +11,14 @@ export default defineConfig({ screenshotFailures: false, headless: true, enabled: true, - provider: 'playwright', + provider: playwright(), + isolate: false, // https://vitest.dev/guide/browser/playwright - instances: [{ browser: 'chromium' }, { browser: 'firefox' }, { browser: 'webkit' }], + instances: [ + { browser: 'chromium' }, + // { browser: 'firefox' }, + // { browser: 'webkit' } + ], }, }, }); diff --git a/yarn.lock b/yarn.lock index a85b0b38ac173..b169f7e631c07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3559,6 +3559,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -7397,13 +7404,14 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" dependencies: - "@vitest/browser": "npm:4.0.0-beta.9" - "@vitest/coverage-v8": "npm:4.0.0-beta.9" + "@vitest/browser": "npm:4.0.0-beta.10" + "@vitest/coverage-v8": "npm:4.0.0-beta.10" + "@vitest/ui": "npm:4.0.0-beta.10" oxlint: "npm:~1.14.0" oxlint-tsgolint: "npm:~0.1.5" playwright: "npm:^1.55.0" typescript: "npm:~5.9.2" - vitest: "npm:4.0.0-beta.9" + vitest: "npm:4.0.0-beta.10" languageName: unknown linkType: soft @@ -13404,15 +13412,15 @@ __metadata: languageName: node linkType: hard -"@vitest/browser@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/browser@npm:4.0.0-beta.9" +"@vitest/browser@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/browser@npm:4.0.0-beta.10" dependencies: "@testing-library/dom": "npm:^10.4.1" "@testing-library/user-event": "npm:^14.6.1" - "@vitest/mocker": "npm:4.0.0-beta.9" - "@vitest/utils": "npm:4.0.0-beta.9" - magic-string: "npm:^0.30.17" + "@vitest/mocker": "npm:4.0.0-beta.10" + "@vitest/utils": "npm:4.0.0-beta.10" + magic-string: "npm:^0.30.18" pixelmatch: "npm:7.1.0" pngjs: "npm:^7.0.0" sirv: "npm:^3.0.1" @@ -13420,7 +13428,7 @@ __metadata: ws: "npm:^8.18.3" peerDependencies: playwright: "*" - vitest: 4.0.0-beta.9 + vitest: 4.0.0-beta.10 webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 peerDependenciesMeta: playwright: @@ -13429,32 +13437,32 @@ __metadata: optional: true webdriverio: optional: true - checksum: 10/5316cf83452995efa9c9daadc5d5884f0fbe04b9330e624e903ab705a22514a42637cbcbf7f2ea1a9b10306ca80c430856f1cc5cc770bff25b74a7391bd53a5e + checksum: 10/49da585b7e5702003584efca16f10a85e2cf6e8f9352e4a012af0a819388cc6be633bb23b37f33d95aa01f86bafc349ce8d83ae37351e1386e9a5db5bac7edec languageName: node linkType: hard -"@vitest/coverage-v8@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/coverage-v8@npm:4.0.0-beta.9" +"@vitest/coverage-v8@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/coverage-v8@npm:4.0.0-beta.10" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.0-beta.9" - ast-v8-to-istanbul: "npm:^0.3.3" + "@vitest/utils": "npm:4.0.0-beta.10" + ast-v8-to-istanbul: "npm:^0.3.4" debug: "npm:^4.4.1" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" istanbul-lib-source-maps: "npm:^5.0.6" - istanbul-reports: "npm:^3.1.7" + istanbul-reports: "npm:^3.2.0" magicast: "npm:^0.3.5" std-env: "npm:^3.9.0" tinyrainbow: "npm:^2.0.0" peerDependencies: - "@vitest/browser": 4.0.0-beta.9 - vitest: 4.0.0-beta.9 + "@vitest/browser": 4.0.0-beta.10 + vitest: 4.0.0-beta.10 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10/964aafbc090187bbfc6eb4ee5526defe439aecef32cd14ce642749e27a0d61d24a03ef2b5f4706e5f8376d47715ac50d3701a6277bfbaff1aacef23702773598 + checksum: 10/56556f73e3f34f7aa005d56715af2fec69c568f3e3be1fc827f5fac6e707159b0d5ef8b57197037a00b612e2f3841017e9b289190a2ad651afe9f9c56b8f7c72 languageName: node linkType: hard @@ -13470,26 +13478,26 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/expect@npm:4.0.0-beta.9" +"@vitest/expect@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/expect@npm:4.0.0-beta.10" dependencies: "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.0-beta.9" - "@vitest/utils": "npm:4.0.0-beta.9" + "@vitest/spy": "npm:4.0.0-beta.10" + "@vitest/utils": "npm:4.0.0-beta.10" chai: "npm:^6.0.1" tinyrainbow: "npm:^2.0.0" - checksum: 10/d2964ec77aedb8bc437f5563d3ce88078552930a5e41a6853ecd3d73f87258d9bccd095a176bed4807dab8aa937d9706ae972211741f7d0045767bfec52e07c6 + checksum: 10/8fd1af7c820096e185b7b060369fb25f59aa8a0152f58fbb815504e9c19cab031cbee392d5853e501086c7f172a4175117cccdcf65504860f3594a9b805dcdca languageName: node linkType: hard -"@vitest/mocker@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/mocker@npm:4.0.0-beta.9" +"@vitest/mocker@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/mocker@npm:4.0.0-beta.10" dependencies: - "@vitest/spy": "npm:4.0.0-beta.9" + "@vitest/spy": "npm:4.0.0-beta.10" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" + magic-string: "npm:^0.30.18" peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -13498,7 +13506,7 @@ __metadata: optional: true vite: optional: true - checksum: 10/4f5cc60cd5e41e053bcdae5f62847d1a56e79756cfa40629b37ed871e8c5b89b1e79a5c2f80ae0ada8c83a077f3542b50cf8f45fb3478383c49cfa5d7a0c8df9 + checksum: 10/a87e99016f4201ca5c73fadfe0036df269118f75b58182bcdbf7838501bb75cabb3d5617d33020d2409004cff727dedeaba555bde0d60fadb6b19faf971c2cb2 languageName: node linkType: hard @@ -13520,34 +13528,34 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.0-beta.9, @vitest/pretty-format@npm:^4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/pretty-format@npm:4.0.0-beta.9" +"@vitest/pretty-format@npm:4.0.0-beta.10, @vitest/pretty-format@npm:^4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/pretty-format@npm:4.0.0-beta.10" dependencies: tinyrainbow: "npm:^2.0.0" - checksum: 10/8a7fc725aa8f0ad7556819955611e5877a0208cfcf053cfa6d319bbe26761e62f20ef29e9dbb028db71cf4082a338ff430274709582ad51a6fa47a36b5db2a8d + checksum: 10/522ba8a2c5d18d8fa853e3b611a62ad858340ef3506f09c8e864e44e9591dc2f6fcc9271593e9a77ff04a9991c86c9e1342aae153ac8df6fe51b7c6066d65ea8 languageName: node linkType: hard -"@vitest/runner@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/runner@npm:4.0.0-beta.9" +"@vitest/runner@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/runner@npm:4.0.0-beta.10" dependencies: - "@vitest/utils": "npm:4.0.0-beta.9" + "@vitest/utils": "npm:4.0.0-beta.10" pathe: "npm:^2.0.3" strip-literal: "npm:^3.0.0" - checksum: 10/d7b3b4e3e34dac248d454fc0c26cdc5e86b32f971421aca67aad31ee9269e324ae5b09153675059ee3037cb5f36956e0a86ab614dfaacc469540c2061221f2a6 + checksum: 10/4bdca8daf6e148c7ce425d9e96bc24900489047078bd248bf3343c9df3da2360d2431e87314c106083aaae993f6e78bcee06b449cbbb782e0b63f6b06d565889 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/snapshot@npm:4.0.0-beta.9" +"@vitest/snapshot@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/snapshot@npm:4.0.0-beta.10" dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.9" - magic-string: "npm:^0.30.17" + "@vitest/pretty-format": "npm:4.0.0-beta.10" + magic-string: "npm:^0.30.18" pathe: "npm:^2.0.3" - checksum: 10/6b02b2aac1c223ffbdb5716486aec864387a41b7dccbb9acdf92644da091a2da25de84f1c1a8f5c4cf0034d7f6a52dcf432caba0f10db692bf5cf939a8291758 + checksum: 10/cb3476f01671b5ee1f5086dab1ddcc8b8e43534231c1c7ae55798b34108a6b93218d9fed1f6c347a021acb3978047574b9260719ef08e6725108b6f63136d9c6 languageName: node linkType: hard @@ -13560,10 +13568,27 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/spy@npm:4.0.0-beta.9" - checksum: 10/45d7b574d99926989cf4f0ce4245e57eb2f3d093df5b5397b6391aea7d0f71b5045fc4cb0807be4a9dff998e01b9e9ec9e852be014c0f14879e30fc013d1321f +"@vitest/spy@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/spy@npm:4.0.0-beta.10" + checksum: 10/31db62bcc8fc51a951b44a488331289671f6d4a63d90c48a0812734ae36503c3479f793b0fced9d1d96764d2a6891e7c88d32b0716bef2353f3f0f7a52565da7 + languageName: node + linkType: hard + +"@vitest/ui@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/ui@npm:4.0.0-beta.10" + dependencies: + "@vitest/utils": "npm:4.0.0-beta.10" + fflate: "npm:^0.8.2" + flatted: "npm:^3.3.3" + pathe: "npm:^2.0.3" + sirv: "npm:^3.0.1" + tinyglobby: "npm:^0.2.14" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + vitest: 4.0.0-beta.10 + checksum: 10/2cdc1d80bb40e93ecb36a5e798be8454c3ec82ca058ae05cfb08dd7f819329d5de5154a1e5c49b63857721604349fd95a31d62a927cf22d48b97ac2191f61e9c languageName: node linkType: hard @@ -13579,14 +13604,14 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "@vitest/utils@npm:4.0.0-beta.9" +"@vitest/utils@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "@vitest/utils@npm:4.0.0-beta.10" dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.9" - loupe: "npm:^3.2.0" + "@vitest/pretty-format": "npm:4.0.0-beta.10" + loupe: "npm:^3.2.1" tinyrainbow: "npm:^2.0.0" - checksum: 10/b6b0d731ffb0bb803857eff1c3b93a638f3ea064b728ca15d17cbe7afbf36c551e0f41d77a7ac405f33db1ab2c7c353180c26a7aed8a7bc17467668c9d686f42 + checksum: 10/ea4b66adbbafdf239f878cbeb64cd7edc9fec5b90edaca1217355bad0aeb125176b729cfb8ac17be464849debc53c5bef5059cbe19a43ef0a8ced449de0c504d languageName: node linkType: hard @@ -14664,7 +14689,7 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.3": +"ast-v8-to-istanbul@npm:^0.3.4": version: 0.3.5 resolution: "ast-v8-to-istanbul@npm:0.3.5" dependencies: @@ -21017,6 +21042,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.3.3": + version: 3.3.3 + resolution: "flatted@npm:3.3.3" + checksum: 10/8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe + languageName: node + linkType: hard + "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -24032,7 +24064,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.7": +"istanbul-reports@npm:^3.2.0": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -26286,10 +26318,10 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.2.0": - version: 3.2.0 - resolution: "loupe@npm:3.2.0" - checksum: 10/80d48e35b014c2ba5886e25a02ee4cc9c1f659b0eca9c4fa8f07051cdc689e0507a763fbe05a63abbd3b7d4640774a722c905d4b1681b4b92c3ba8f87d96fea2 +"loupe@npm:^3.2.1": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10/a4d78ec758aaa04e0e35d5cd1c15e970beb9cdbfd3d0f34f98b9bcda489f896a7190b3b6cc40b7a6dcb8e97e82e96eafaae10096aaa469804acdba6f7c2bde5f languageName: node linkType: hard @@ -26386,6 +26418,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.18": + version: 0.30.18 + resolution: "magic-string@npm:0.30.18" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10/dd180f174cfe066f501dc700ec58815eae3cbde402308f3326574bad09a5ff9c9c5b6fc71502e9d1c663254614fe21679c6360cd39aaeedd2cee1b40e14669f2 + languageName: node + linkType: hard + "magic-string@npm:^0.30.5": version: 0.30.11 resolution: "magic-string@npm:0.30.11" @@ -35177,10 +35218,10 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.1.1": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10/0d54139e9dbc6ef33349768fa78890a4d708d16a7ab68e4e4ef3bb740609ddf0f9fd13292c2f413fbba756166c97051a657181c8f7ae92ade690604f183cc01d +"tinypool@npm:^2.0.0": + version: 2.0.0 + resolution: "tinypool@npm:2.0.0" + checksum: 10/0f9dbeb25b2f0d8243321d8044254accf35b14d1c5b343115dd86ef3a0aacd64b68fc6d319ea714a295165ed355e93d031757a7505067448ffd0fa688697a80d languageName: node linkType: hard @@ -36812,28 +36853,28 @@ __metadata: languageName: node linkType: hard -"vitest@npm:4.0.0-beta.9": - version: 4.0.0-beta.9 - resolution: "vitest@npm:4.0.0-beta.9" +"vitest@npm:4.0.0-beta.10": + version: 4.0.0-beta.10 + resolution: "vitest@npm:4.0.0-beta.10" dependencies: - "@vitest/expect": "npm:4.0.0-beta.9" - "@vitest/mocker": "npm:4.0.0-beta.9" - "@vitest/pretty-format": "npm:^4.0.0-beta.9" - "@vitest/runner": "npm:4.0.0-beta.9" - "@vitest/snapshot": "npm:4.0.0-beta.9" - "@vitest/spy": "npm:4.0.0-beta.9" - "@vitest/utils": "npm:4.0.0-beta.9" + "@vitest/expect": "npm:4.0.0-beta.10" + "@vitest/mocker": "npm:4.0.0-beta.10" + "@vitest/pretty-format": "npm:^4.0.0-beta.10" + "@vitest/runner": "npm:4.0.0-beta.10" + "@vitest/snapshot": "npm:4.0.0-beta.10" + "@vitest/spy": "npm:4.0.0-beta.10" + "@vitest/utils": "npm:4.0.0-beta.10" debug: "npm:^4.4.1" es-module-lexer: "npm:^1.7.0" expect-type: "npm:^1.2.2" - magic-string: "npm:^0.30.17" + magic-string: "npm:^0.30.18" pathe: "npm:^2.0.3" picomatch: "npm:^4.0.3" std-env: "npm:^3.9.0" tinybench: "npm:^2.9.0" tinyexec: "npm:^0.3.2" tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" + tinypool: "npm:^2.0.0" tinyrainbow: "npm:^2.0.0" vite: "npm:^6.0.0 || ^7.0.0-0" why-is-node-running: "npm:^2.3.0" @@ -36841,8 +36882,8 @@ __metadata: "@edge-runtime/vm": "*" "@types/debug": ^4.1.12 "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 4.0.0-beta.9 - "@vitest/ui": 4.0.0-beta.9 + "@vitest/browser": 4.0.0-beta.10 + "@vitest/ui": 4.0.0-beta.10 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -36862,7 +36903,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10/a42aca09a8d141e1728a1b55f43fcf7daf2f68f70c8d42c02d3c4017971a28cad722698bc98f53a297aa8ef815ddbf68d35af125fdb417a79934e3faacdb20a3 + checksum: 10/d1edcb237cfd1539b5876812de940a305cb74165cc83a24a45aaf73a11bec6cf76746e4f83427464a26a89d40697ca2e3ad5e4bdc9e14079e6391e620de8ab9e languageName: node linkType: hard From caf65deb8346ba13abc69ce995845cde934e1601 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 18:06:31 -0300 Subject: [PATCH 083/251] update test config --- packages/e2ee/package.json | 2 +- packages/e2ee/vitest.config.ts | 1 + yarn.lock | 40 +++++++++++++++------------------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json index 2033bf68f2f44..f51c22a4db9b0 100644 --- a/packages/e2ee/package.json +++ b/packages/e2ee/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc -p tsconfig.json", "lint": "oxlint --type-aware", "testunit": "vitest run --browser=chromium --coverage", - "test": "vitest run" + "test": "vitest" }, "devDependencies": { "@vitest/browser": "4.0.0-beta.10", diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts index dd0c700855e86..a327737960fe8 100644 --- a/packages/e2ee/vitest.config.ts +++ b/packages/e2ee/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser/providers/playwright'; export default defineConfig({ test: { + includeTaskLocation: true, coverage: { provider: 'v8', include: ['src/*.ts'], diff --git a/yarn.lock b/yarn.lock index b4747a99831ec..f4fa0620fce30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5079,7 +5079,7 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.55.0, @playwright/test@npm:~1.55.0": +"@playwright/test@npm:~1.55.0": version: 1.55.0 resolution: "@playwright/test@npm:1.55.0" dependencies: @@ -11323,6 +11323,15 @@ __metadata: languageName: node linkType: hard +"@types/chart.js@npm:^2.9.41": + version: 2.9.41 + resolution: "@types/chart.js@npm:2.9.41" + dependencies: + moment: "npm:^2.10.2" + checksum: 10/302b251633bb469410c0a8bd3e9b30fdc08c14f9d92998095ae3849a1c3069f61e70df530e6598e6416b3abd13d15dd4eb4c45914300a679ce4c8dee21847301 + languageName: node + linkType: hard + "@types/codemirror@npm:~5.60.16": version: 5.60.16 resolution: "@types/codemirror@npm:5.60.16" @@ -12004,7 +12013,7 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:~4.0.9": +"@types/js-yaml@npm:^4.0.9, @types/js-yaml@npm:~4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6 @@ -12327,16 +12336,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^24.3.0": - version: 24.3.0 - resolution: "@types/node@npm:24.3.0" - dependencies: - undici-types: "npm:~7.10.0" - checksum: 10/1331c2d0e9a512ac27a016b4df3eff92317e4603dbbbab31731275dff14d3a04847a50c5776cbf94f99ff4dedac0ba5f721dce8cea020d8eea5e21711fd964b0 - languageName: node - linkType: hard - -"@types/node@npm:~22.16.5": +"@types/node@npm:~22.16.1, @types/node@npm:~22.16.5": version: 22.16.5 resolution: "@types/node@npm:22.16.5" dependencies: @@ -27557,7 +27557,7 @@ __metadata: languageName: node linkType: hard -"moment@npm:^2.29.1, moment@npm:^2.29.4, moment@npm:^2.30.1": +"moment@npm:^2.10.2, moment@npm:^2.29.1, moment@npm:^2.29.4, moment@npm:^2.30.1": version: 2.30.1 resolution: "moment@npm:2.30.1" checksum: 10/ae42d876d4ec831ef66110bdc302c0657c664991e45cf2afffc4b0f6cd6d251dde11375c982a5c0564ccc0fa593fc564576ddceb8c8845e87c15f58aa6baca69 @@ -32474,11 +32474,14 @@ __metadata: resolution: "rocket.chat@workspace:." dependencies: "@changesets/cli": "npm:^2.27.11" - "@playwright/test": "npm:^1.55.0" - "@types/node": "npm:^24.3.0" + "@types/chart.js": "npm:^2.9.41" + "@types/js-yaml": "npm:^4.0.9" + "@types/node": "npm:~22.16.1" "@types/stream-buffers": "npm:^3.0.7" node-gyp: "npm:^10.2.0" + ts-node: "npm:^10.9.2" turbo: "npm:~2.5.6" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -36128,13 +36131,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.10.0": - version: 7.10.0 - resolution: "undici-types@npm:7.10.0" - checksum: 10/1f3fe777937690ab8a7a7bccabc8fdf4b3171f4899b5a384fb5f3d6b56c4b5fec2a51fbf345c9dd002ff6716fd440a37fa8fdb0e13af8eca8889f25445875ba3 - languageName: node - linkType: hard - "undici@npm:^5.25.4, undici@npm:^5.28.5": version: 5.29.0 resolution: "undici@npm:5.29.0" From 1d5868e5d79ca4bd9bac60abcf4a7b36e5ca954f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 18:17:43 -0300 Subject: [PATCH 084/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + packages/e2ee/src/index.ts | 4 ++-- packages/e2ee/src/keychain.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index f60dfd4c92b35..d52ee4e4d17d2 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -4,6 +4,7 @@ - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) +- [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 0fb0d2cab8ffa..1fbeca4d57f2c 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -17,7 +17,7 @@ const generateMnemonicPhrase = async (length: number): Promise => { }; export interface KeyService { - userId: () => string; + userId: () => string | null; fetchMyKeys: () => Promise; persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; } @@ -189,7 +189,7 @@ export default class E2EE { return baseKey; } - const userId = await this.#service.userId(); + const userId = this.#service.userId(); if (!userId) { return err(new Error('User not found')); diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts index a23ba02d94d36..ed7586a2c8787 100644 --- a/packages/e2ee/src/keychain.ts +++ b/packages/e2ee/src/keychain.ts @@ -22,7 +22,7 @@ export class Keychain { }); } - private async getDb(): Promise<{ userId: string; db: IDBDatabase }> { + private getDb(): { userId: string; db: IDBDatabase } { if (!this.db) { throw new Error('Keychain is not initialized.'); } From b3cccee45e8cfe712a20ac4d3f1ed695e7171147 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 18:28:54 -0300 Subject: [PATCH 085/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index d52ee4e4d17d2..899e8ec8e3671 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -8,6 +8,7 @@ - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) +- [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) ## Tasks From c588768fa036a22d13c5d5934aaa53f8b00860a8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 18:30:01 -0300 Subject: [PATCH 086/251] remove dead code --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index d0dd88c7b6de2..71d62b784c70c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -608,7 +608,6 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - console.log(error); return logger.error('Error encrypting group key: ', error); } @@ -799,15 +798,6 @@ export class E2ERoom extends Emitter { } } - provideKeyToUser(keyId: string) { - if (this.keyID !== keyId) { - return; - } - - void this.encryptKeyForOtherParticipants(); - this.setState('READY'); - } - onStateChange(cb: () => void) { this.on('STATE_CHANGED', cb); return () => this.off('STATE_CHANGED', cb); From 94e01b113080dac0a3d98b5be176b13be74481a7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 1 Sep 2025 18:53:36 -0300 Subject: [PATCH 087/251] keychain state --- packages/e2ee/src/keychain.ts | 36 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts index ed7586a2c8787..0d620c243197f 100644 --- a/packages/e2ee/src/keychain.ts +++ b/packages/e2ee/src/keychain.ts @@ -1,16 +1,20 @@ +export interface KeychainState { + userId: string; + db: IDBDatabase; +} + export class Keychain { - private db: { userId: string; db: IDBDatabase } | false; + private state: KeychainState | false; constructor() { - this.db = false; + this.state = false; } - public init(userId: string): Promise { - return new Promise((resolve, reject) => { + public async init(userId: string): Promise { + const db = await new Promise((resolve, reject) => { const request = indexedDB.open(`E2eeChatDB_${userId}`, 1); request.onerror = (): void => reject(request.error); request.onsuccess = (): void => { - this.db = { userId, db: request.result }; resolve(request.result); }; request.onupgradeneeded = (): void => { @@ -20,17 +24,19 @@ export class Keychain { } }; }); + + this.state = { userId, db }; } - private getDb(): { userId: string; db: IDBDatabase } { - if (!this.db) { + private getState(): KeychainState { + if (!this.state) { throw new Error('Keychain is not initialized.'); } - return this.db; + return this.state; } public async savePrivateKey(privateKey: CryptoKey): Promise { - const { userId, db } = await this.getDb(); + const { db, userId } = this.getState(); const res = await new Promise((resolve, reject) => { const transaction = db.transaction('CryptoKeys', 'readwrite'); @@ -48,8 +54,9 @@ export class Keychain { } public async getPrivateKey(): Promise { - const { db, userId } = await this.getDb(); - return new Promise((resolve, reject) => { + const { db, userId } = this.getState(); + + const privateKey = await new Promise((resolve, reject) => { const transaction = db.transaction('CryptoKeys', 'readonly'); const store = transaction.objectStore('CryptoKeys'); const request = store.get(userId); @@ -58,11 +65,14 @@ export class Keychain { }; request.onerror = (): void => reject(new Error('Failed to get private key', { cause: request.error })); }); + + return privateKey; } public async deletePrivateKey(): Promise { - const { db, userId } = await this.getDb(); - return new Promise((resolve, reject) => { + const { db, userId } = this.getState(); + + await new Promise((resolve, reject) => { const transaction = db.transaction('CryptoKeys', 'readwrite'); const store = transaction.objectStore('CryptoKeys'); const request = store.delete(userId); From 0d9d8482dc048ff9262080b9ef1fe630ae951843 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 17:19:14 +0000 Subject: [PATCH 088/251] fix failing tests due to settings --- apps/meteor/client/lib/e2ee/helper.ts | 12 +- apps/meteor/client/lib/e2ee/logger.ts | 25 ++- .../client/lib/e2ee/rocketchat.e2e.room.ts | 206 +++++++----------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- .../root/hooks/loggedIn/useE2EEncryption.ts | 1 + apps/meteor/tests/e2e/e2e-encryption.spec.ts | 50 ++++- .../page-objects/fragments/home-sidenav.ts | 16 +- packages/e2ee/src/__tests__/group.spec.ts | 30 +-- packages/e2ee/src/group.ts | 17 +- packages/e2ee/src/index.ts | 3 +- packages/e2ee/src/keychain.ts | 12 +- 11 files changed, 185 insertions(+), 189 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index fe68e47e73c2f..b70d547c0ea65 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -44,6 +44,14 @@ export async function encryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); } +/** + * Encrypts data using AES-CBC. + * @param vector The initialization vector. + * @param key The encryption key. + * @param data The data to encrypt. + * @returns The encrypted data. + * @deprecated Use {@link encryptAesGcm} instead. + */ export async function encryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } @@ -133,9 +141,9 @@ export async function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArr export async function deriveKey(salt: Uint8Array, baseKey: CryptoKey) { return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt, iterations: 1000, hash: 'SHA-256' }, + { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, baseKey, - { name: 'AES-CBC', length: 256 }, + { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'], ); diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 3a7b74fc209cf..0890657daa242 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,39 +1,48 @@ class Logger { - level: 0 | 1 | 2 | 3 | 4; + level: 0 | 1 | 2 | 3 | 4 | 5; - constructor(level: 0 | 1 | 2 | 3 | 4) { + prefix: string; + + constructor(prefix: string, level: 0 | 1 | 2 | 3 | 4 | 5) { this.level = level; + this.prefix = prefix; + } + + table(tabularData: T, properties?: readonly Extract[]) { + if (this.level <= 0) { + console.table(tabularData, properties); + } } log(...data: unknown[]) { if (this.level <= 0) { - console.log(...data); + console.log(this.prefix, ...data); } } info(...data: unknown[]) { if (this.level <= 1) { - console.info(...data); + console.info(this.prefix, ...data); } } warn(...data: unknown[]) { if (this.level <= 2) { - console.warn(...data); + console.warn(this.prefix, ...data); } } error(...data: unknown[]) { if (this.level <= 3) { - console.error(...data); + console.error(this.prefix, ...data); } } debug(...data: unknown[]) { if (this.level <= 4) { - console.debug(...data); + console.debug(this.prefix, ...data); } } } -export const logger = new Logger(4); +export const logger = new Logger('E2EE', 0); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 71d62b784c70c..84818f2b1ec72 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,6 +1,5 @@ import { Base64 } from '@rocket.chat/base64'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; -import { Jwk } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; @@ -12,22 +11,18 @@ import { joinVectorAndEcryptedData, splitVectorAndEcryptedData, encryptRSA, - encryptAesCbc, encryptAesGcm, - decryptAesGcm, decryptRSA, - decryptAesCbc, + decryptAesGcm, + generateAesGcmKey, exportJWKKey, - importAesCbcKey, + importAesGcmKey, importRSAKey, readFileAsArrayBuffer, encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, - generateAesGcmKey, - generateAesCbcKey, - importAesGcmKey, } from './helper'; import { logger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -110,7 +105,7 @@ export class E2ERoom extends Emitter { this.once('READY', () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === RoomManager.opened) { - logger.log(`[PREV: ${prev}]`, 'State CHANGED'); + this.log(`[PREV: ${prev}]`, 'State CHANGED'); } }); this.on('STATE_CHANGED', () => this.handshake()); @@ -118,6 +113,14 @@ export class E2ERoom extends Emitter { this.setState('NOT_STARTED'); } + log(...msg: unknown[]) { + logger.log(`ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + } + + error(...msg: unknown[]) { + logger.error(`ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + } + hasSessionKey() { return !!this.groupSessionKey; } @@ -131,12 +134,12 @@ export class E2ERoom extends Emitter { const nextState = filterMutation(currentState, requestedState); if (!nextState) { - logger.error(`invalid state ${currentState} -> ${requestedState}`); + this.error(`invalid state ${currentState} -> ${requestedState}`); return; } this.state = nextState; - logger.log(currentState, '->', nextState); + this.log(currentState, '->', nextState); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -162,13 +165,13 @@ export class E2ERoom extends Emitter { } pause() { - logger.log('PAUSED', this[PAUSED], '->', true); + this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - logger.log('PAUSED', this[PAUSED], '->', false); + this.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } @@ -215,28 +218,28 @@ export class E2ERoom extends Emitter { const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { - logger.log('decryptSubscriptions nothing to do'); + this.log('decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - logger.log('decryptSubscriptions updating lastMessage'); + this.log('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - logger.log('decryptSubscriptions Done'); + this.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - logger.log('decryptOldRoomKeys nothing to do'); + this.log('decryptOldRoomKeys nothing to do'); return; } @@ -249,22 +252,21 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - logger.error( + this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e, ); keys.push({ ...key, E2EKey: null }); } } this.oldKeys = keys; - logger.log('decryptOldRoomKeys Done'); + this.log('decryptOldRoomKeys Done'); } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - logger.log('exportOldRoomKeys starting'); + this.log('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { - logger.log('exportOldRoomKeys nothing to do'); + this.log('exportOldRoomKeys nothing to do'); return; } @@ -281,14 +283,13 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - logger.error( + this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e, ); } } - logger.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); return keys; } @@ -320,7 +321,7 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - logger.error('Error fetching group key: ', error); + this.error('Error fetching group key: ', error); return; } @@ -334,10 +335,10 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - logger.log('Requesting room key'); + this.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - logger.error('Error requesting room key: ', error); + this.error('Error during handshake: ', error); this.setState('ERROR'); } } @@ -347,17 +348,7 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - const jwk = Jwk.parse(await this.exportSessionKey(key)); - try { - return await importAesGcmKey(jwk); - } catch (error) { - logger.error(error); - try { - return await importAesCbcKey(jwk); - } catch (error) { - throw new Error('Failed to import session key', { cause: error }); - } - } + return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); } async exportSessionKey(key: string) { @@ -373,7 +364,7 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string) { - logger.log('Importing room key ->', this.roomId); + this.log('Importing room key ->', this.roomId); // Get existing group key // const keyID = groupKey.slice(0, 12); groupKey = groupKey.slice(12); @@ -387,7 +378,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - logger.error('Error decrypting group key: ', error); + this.error('Error decrypting group key: ', error); return false; } @@ -397,61 +388,34 @@ export class E2ERoom extends Emitter { this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } - const jwk = Jwk.parse(this.sessionKeyExportedString); - - if (Jwk.isAesGcm(jwk)) { - try { - const key = await importAesGcmKey(jwk); - this.groupSessionKey = key; - return true; - } catch (error) { - logger.error('Error importing AES-GCM key: ', error); - return false; - } - } - - if (Jwk.isAesCbc(jwk)) { - try { - const key = await importAesCbcKey(jwk); - this.groupSessionKey = key; - return true; - } catch (error) { - logger.error('Error importing AES-CBC key: ', error); - return false; - } + // Import session key for use. + try { + const key = await importAesGcmKey(JSON.parse(this.sessionKeyExportedString!)); + // Key has been obtained. E2E is now in session. + this.groupSessionKey = key; + } catch (error) { + this.error('Error importing group key: ', error); + return false; } - } - /** - * @deprecated - * Use {@link createNewGroupKey} instead. - */ - async createOldGroupKey() { - this.groupSessionKey = await generateAesCbcKey(); - const sessionKeyExported = await exportJWKKey(this.groupSessionKey); - this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); - this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + return true; } async createNewGroupKey() { this.groupSessionKey = await generateAesGcmKey(); + const sessionKeyExported = await exportJWKKey(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } async createGroupKey() { - logger.log('Creating room key'); + this.log('Creating room key'); try { await this.createNewGroupKey(); await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - - if (!e2e.publicKey) { - throw new Error('No public key found'); - } - - const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey); + const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); if (myKey) { await sdk.rest.post('/v1/e2e.updateGroupKey', { rid: this.roomId, @@ -461,15 +425,15 @@ export class E2ERoom extends Emitter { await this.encryptKeyForOtherParticipants(); } } catch (error) { - logger.error('Error exporting group key: ', error); + this.error('Error exporting group key: ', error); throw error; } } async resetRoomKey() { - logger.log('Resetting room key'); + this.log('Resetting room key'); if (!e2e.publicKey) { - logger.error('Cannot reset room key. No public key found.'); + this.error('Cannot reset room key. No public key found.'); return; } @@ -480,17 +444,17 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - logger.log(`Room key reset done for room ${this.roomId}`); + this.log(`Room key reset done for room ${this.roomId}`); return e2eNewKeys; } catch (error) { - logger.error('Error resetting group key: ', error); + this.error('Error resetting group key: ', error); throw error; } } onRoomKeyReset(keyID: string) { - logger.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -519,16 +483,13 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const publicKey = user.e2e?.public_key; - if (!publicKey) { - return; - } - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(JSON.parse(publicKey)); + const jwk = JSON.parse(user.e2e!.public_key!); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(jwk); if (!encryptedGroupKey) { return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(publicKey, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(jwk, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; @@ -539,11 +500,11 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return logger.error('Error getting room users: ', error); + return this.error('Error getting room users: ', error); } } - async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { + async encryptOldKeysForParticipant(publicKey: JsonWebKey, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { if (!oldRoomKeys || oldRoomKeys.length === 0) { return; } @@ -551,9 +512,9 @@ export class E2ERoom extends Emitter { let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await importRSAKey(publicKey, ['encrypt']); } catch (error) { - return logger.error('Error importing user key: ', error); + return this.error('Error importing user key: ', error); } try { @@ -569,7 +530,7 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return logger.error('Error encrypting user key: ', error); + return this.error('Error encrypting user key: ', error); } } @@ -578,7 +539,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(publicKey, ['encrypt']); } catch (error) { - return logger.error('Error importing user key: ', error); + return this.error('Error importing user key: ', error); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -588,7 +549,7 @@ export class E2ERoom extends Emitter { const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; } catch (error) { - return logger.error('Error encrypting user key: ', error); + return this.error('Error encrypting user key: ', error); } } @@ -608,7 +569,8 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - return logger.error('Error encrypting group key: ', error); + console.log(error); + return this.error('Error encrypting group key: ', error); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); @@ -642,24 +604,10 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - - const algo = this.groupSessionKey.algorithm; - - switch (algo.name) { - case 'AES-GCM': { - const result = await encryptAesGcm(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); - } - case 'AES-CBC': { - const result = await encryptAesCbc(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); - } - default: { - throw new Error(`Unsupported encryption algorithm: ${algo}`); - } - } + const result = await encryptAesGcm(vector, this.groupSessionKey, data); + return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); } catch (error) { - logger.error('Error encrypting message: ', error); + this.error('Error encrypting message: ', error); throw error; } } @@ -748,11 +696,7 @@ export class E2ERoom extends Emitter { } async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { - if (key.algorithm.name === 'AES-GCM') { - const result = await decryptAesGcm(vector, key, cipherText); - return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); - } - const result = await decryptAesCbc(vector, key, cipherText); + const result = await decryptAesGcm(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } @@ -777,7 +721,7 @@ export class E2ERoom extends Emitter { } return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - logger.error('Error decrypting message: ', error, message); + this.error('Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; } } @@ -793,11 +737,20 @@ export class E2ERoom extends Emitter { } return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - logger.error('Error decrypting message: ', error, message); + this.error('Error decrypting message: ', error, message); return { msg: t('E2E_Key_Error') }; } } + provideKeyToUser(keyId: string) { + if (this.keyID !== keyId) { + return; + } + + void this.encryptKeyForOtherParticipants(); + this.setState('READY'); + } + onStateChange(cb: () => void) { this.on('STATE_CHANGED', cb); return () => this.off('STATE_CHANGED', cb); @@ -813,9 +766,10 @@ export class E2ERoom extends Emitter { const usersWithKeys = await Promise.all( users.map(async (user) => { const { _id, public_key } = user; - const key = await this.encryptGroupKeyForParticipant(JSON.parse(public_key)); + const jwk = JSON.parse(public_key); + const key = await this.encryptGroupKeyForParticipant(jwk); if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(jwk, decryptedOldGroupKeys); return { _id, key, oldKeys }; } return { _id, key }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 559048ec96561..a4e574a9e6b00 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -131,7 +131,6 @@ class E2E extends Emitter<{ } async onSubscriptionChanged(sub: ISubscription) { - logger.log('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -189,6 +188,7 @@ class E2E extends Emitter<{ excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } + logger.table(subscriptions); for (const sub of subscriptions) { void this.onSubscriptionChanged(sub); } diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 7b28edcbbeee7..76c06f0d59afa 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -29,6 +29,7 @@ export const useE2EEncryption = () => { } if (enabled && !adminEmbedded) { + logger.log('enabled starting client'); e2e.startClient(); } else { e2e.setState('DISABLED'); diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index bc28ed7198ec8..4ff769ce3f9c6 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -19,10 +19,32 @@ import { import { ExportMessagesTab } from './page-objects/fragments/export-messages-tab'; import { FileUploadModal } from './page-objects/fragments/file-upload-modal'; import { LoginPage } from './page-objects/login'; +import { getSettingValueById } from './utils'; import { test, expect } from './utils/test'; -test.beforeAll(async () => { +const settings = { + E2E_Enable: false as unknown, + E2E_Allow_Unencrypted_Messages: false as unknown, + E2E_Enable_Encrypt_Files: false as unknown, + E2E_Enabled_Default_DirectRooms: false as unknown, + E2E_Enabled_Default_PrivateRooms: false as unknown, +}; + +test.beforeAll(async ({ api }) => { await injectInitialData(); + settings.E2E_Enable = await getSettingValueById(api, 'E2E_Enable'); + settings.E2E_Allow_Unencrypted_Messages = await getSettingValueById(api, 'E2E_Allow_Unencrypted_Messages'); + settings.E2E_Enable_Encrypt_Files = await getSettingValueById(api, 'E2E_Enable_Encrypt_Files'); + settings.E2E_Enabled_Default_DirectRooms = await getSettingValueById(api, 'E2E_Enabled_Default_DirectRooms'); + settings.E2E_Enabled_Default_PrivateRooms = await getSettingValueById(api, 'E2E_Enabled_Default_PrivateRooms'); +}); + +test.afterAll(async ({ api }) => { + await api.post('/settings/E2E_Enable', { value: settings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: settings.E2E_Enable_Encrypt_Files }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: settings.E2E_Enabled_Default_DirectRooms }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: settings.E2E_Enabled_Default_PrivateRooms }); }); test.describe('initial setup', () => { @@ -31,11 +53,13 @@ test.describe('initial setup', () => { test.beforeAll(async ({ api }) => { await api.post('/settings/E2E_Enable', { value: true }); await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + await api.post('/settings/E2E_Enable', { value: settings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); }); test.beforeEach(async ({ api, page }) => { @@ -140,8 +164,8 @@ test.describe('basic features', () => { }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + await api.post('/settings/E2E_Enable', { value: settings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); }); test.beforeEach(async ({ api, page }) => { @@ -326,11 +350,15 @@ test.describe.serial('e2e-encryption', () => { test.beforeAll(async ({ api }) => { await api.post('/settings/E2E_Enable', { value: true }); await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + await api.post('/settings/E2E_Enable', { value: settings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); + await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: settings.E2E_Enabled_Default_DirectRooms }); + await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: settings.E2E_Enabled_Default_PrivateRooms }); }); test.beforeEach(async ({ page }) => { @@ -672,7 +700,7 @@ test.describe.serial('e2e-encryption', () => { }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable_Encrypt_Files', { value: true }); + await api.post('/settings/E2E_Enable_Encrypt_Files', { value: settings.E2E_Enable_Encrypt_Files }); await api.post('/settings/FileUpload_MediaTypeBlackList', { value: 'image/svg+xml' }); }); @@ -747,7 +775,7 @@ test.describe.serial('e2e-encryption', () => { }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); }); test('expect slash commands to be disabled in an e2ee room', async ({ page }) => { @@ -953,8 +981,8 @@ test.describe.fixme('e2ee room setup', () => { }); test.afterAll(async ({ api }) => { - await api.post('/settings/E2E_Enable', { value: false }); - await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false }); + await api.post('/settings/E2E_Enable', { value: settings.E2E_Enable }); + await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: settings.E2E_Allow_Unencrypted_Messages }); }); test.afterEach(async ({ api }) => { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 29208e2938d85..24c51c39a5295 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -236,11 +236,17 @@ export class HomeSidenav { // Expand and wait for checkbox to appear before clicking await this.advancedSettingsAccordion.click(); await expect(this.checkboxEncryption).toBeVisible(); - await this.page - .locator('span') - .filter({ hasText: /^Encrypted$/ }) - .locator('i') - .click(); + + const encryptedField = this.page.locator('fieldset > div', { hasText: 'Encrypted' }); + const encryptedFieldText = await encryptedField.textContent(); + if (!encryptedFieldText?.includes('End-to-end encrypted channel')) { + await this.page + .locator('span') + .filter({ hasText: /^Encrypted$/ }) + .locator('i') + .click(); + } + await this.page.getByRole('button', { name: 'Create', exact: true }).click(); await toastMessages.dismissToast('success'); diff --git a/packages/e2ee/src/__tests__/group.spec.ts b/packages/e2ee/src/__tests__/group.spec.ts index b91d4f8cce76e..f2839e8499965 100644 --- a/packages/e2ee/src/__tests__/group.spec.ts +++ b/packages/e2ee/src/__tests__/group.spec.ts @@ -13,13 +13,11 @@ describe('EncryptionClient', () => { describe('User Management', () => { it('should register a new user and store their data via the service', async () => { - const user = new EncryptionClient(mockService); const userId = 'alice@example.com'; const password = 'password123'; + const user = await EncryptionClient.initialize(userId, mockService); const spy = vi.spyOn(mockService, 'registerUser'); - - await user.initialize(userId); await user.register(password); // Check that the library called the service correctly @@ -30,18 +28,16 @@ describe('EncryptionClient', () => { }); it('should log in a user with a passphrase if key is not local', async () => { - const registrationService = new MockChatService(); - const registrant = new EncryptionClient(registrationService); + const chatService = new MockChatService(); const userId = 'bob@example.com'; const password = 'password-bob'; + const registrant = await EncryptionClient.initialize(userId, chatService); // 1. Bob registers on one device, populating the service. - await registrant.initialize(userId); await registrant.register(password); // 2. Bob tries to log in on a *new device* (simulated by a new instance and empty IndexedDB). - const loginUser = new EncryptionClient(registrationService); - await loginUser.initialize(userId); + const loginUser = await EncryptionClient.initialize(userId, chatService); await loginUser.login(password); // @ts-expect-error - Accessing private property for test verification @@ -51,20 +47,18 @@ describe('EncryptionClient', () => { }); it('should permanently delete the account from the device storage', async () => { - const user = new EncryptionClient(mockService); const userId = 'frank@example.com'; - await user.initialize(userId); + const user = await EncryptionClient.initialize(userId, mockService); await user.register('password-frank'); await user.deleteAccountFromDevice(); // Verify in-memory session is cleared // @ts-expect-error - expect(user.userId).toBe(false); + expect(user.userId).toBe(''); // Verify login fails without a passphrase, proving the key is gone - const userInNewSession = new EncryptionClient(mockService); - await userInNewSession.initialize(userId); + const userInNewSession = await EncryptionClient.initialize(userId, mockService); await expect(userInNewSession.login()).rejects.toThrow('Private key not found locally. Passphrase is required for recovery.'); }); }); @@ -73,13 +67,9 @@ describe('EncryptionClient', () => { it('should allow users to create groups, send messages, and for new users to decrypt history', async () => { // --- SETUP --- // 1. Alice, Bob, and Carol register with the same chat service. - const alice = new EncryptionClient(mockService); - const bob = new EncryptionClient(mockService); - const carol = new EncryptionClient(mockService); - - await alice.initialize('alice'); - await bob.initialize('bob'); - await carol.initialize('carol'); + const alice = await EncryptionClient.initialize('alice', mockService); + const bob = await EncryptionClient.initialize('bob', mockService); + const carol = await EncryptionClient.initialize('carol', mockService); await alice.register('alice-pass'); await bob.register('bob-pass'); diff --git a/packages/e2ee/src/group.ts b/packages/e2ee/src/group.ts index 266b13813f848..8adb0edb66e9c 100644 --- a/packages/e2ee/src/group.ts +++ b/packages/e2ee/src/group.ts @@ -37,23 +37,24 @@ export interface ChatService { } export class EncryptionClient { - private userId: string | false; + private userId: string; private privateKey: CryptoKey | false; private groupKeys: Map; private keychain: Keychain; private chatService: ChatService; - constructor(chatService: ChatService) { - this.userId = false; + constructor(userId: string, chatService: ChatService, keychain: Keychain) { + this.userId = userId; this.privateKey = false; this.groupKeys = new Map(); this.chatService = chatService; - this.keychain = new Keychain(); + this.keychain = keychain; } - public async initialize(userId: string): Promise { - this.userId = userId; - await this.keychain.init(userId); + public static async initialize(userId: string, chatService: ChatService): Promise { + const keychain = await Keychain.init(userId); + const client = new EncryptionClient(userId, chatService, keychain); + return client; } /** @@ -213,7 +214,7 @@ export class EncryptionClient { * to allow for easier login next time. */ public logout(): void { - this.userId = false; + this.userId = ''; this.privateKey = false; this.groupKeys.clear(); } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 1fbeca4d57f2c..8c33baaeda464 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -9,6 +9,7 @@ import { stringifyUint8Array, parseUint8Array } from './base64.ts'; import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; import { /** decryptAesCbc, encryptAesCbc, **/ encryptAesGcm, decryptAesGcm } from './aes.ts'; +// import { Keychain } from './keychain.ts'; const generateMnemonicPhrase = async (length: number): Promise => { const { v1 } = await import('./word-list.ts'); @@ -47,11 +48,9 @@ export interface KeyPair { export default class E2EE { #service: KeyService; - // #client: EncryptionClient; constructor(service: KeyService) { this.#service = service; - // this.#client = new EncryptionClient(); } async loadKeys(keys: EncryptedKeyPair): AsyncResult { diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts index 0d620c243197f..89c342b33ac89 100644 --- a/packages/e2ee/src/keychain.ts +++ b/packages/e2ee/src/keychain.ts @@ -4,15 +4,15 @@ export interface KeychainState { } export class Keychain { - private state: KeychainState | false; + private state: KeychainState; - constructor() { - this.state = false; + constructor(state: KeychainState) { + this.state = state; } - public async init(userId: string): Promise { + public static async init(userId: string): Promise { const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(`E2eeChatDB_${userId}`, 1); + const request = indexedDB.open(`E2eeChatDB`, 1); request.onerror = (): void => reject(request.error); request.onsuccess = (): void => { resolve(request.result); @@ -25,7 +25,7 @@ export class Keychain { }; }); - this.state = { userId, db }; + return new Keychain({ userId, db }); } private getState(): KeychainState { From 2c3fd00b3af4e8f8798815f1a590528ebd38e2c8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 17:48:49 +0000 Subject: [PATCH 089/251] fix all e2e-encryption tests --- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 22 +++++++------------- packages/e2ee/src/index.ts | 6 +++--- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 4ff769ce3f9c6..a76c303282aa8 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -502,7 +502,7 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('hello @user1'); - const userMention = await page.getByRole('button', { + const userMention = page.getByRole('button', { name: 'user1', }); @@ -520,7 +520,7 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel?'); - const channelMention = await page.getByRole('button', { + const channelMention = page.getByRole('button', { name: 'general', }); @@ -542,11 +542,11 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.content.sendMessage('Are you in the #general channel, @user1 ?'); - const channelMention = await page.getByRole('button', { + const channelMention = page.getByRole('button', { name: 'general', }); - const userMention = await page.getByRole('button', { + const userMention = page.getByRole('button', { name: 'user1', }); @@ -900,7 +900,7 @@ test.describe.serial('e2e-encryption', () => { test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { const channelName = faker.string.uuid(); - await restoreState(page, Users.userE2EE, { except: ['private_key', 'public_key', 'e2e.random_password'] }); + await restoreState(page, Users.userE2EE, { except: ['private_key', 'public_key', 'e2e.randomPassword'] }); await poHomeChannel.sidenav.createEncryptedChannel(channelName); @@ -965,7 +965,7 @@ test.describe.serial('e2e-encryption', () => { test.use({ storageState: Users.admin.state }); -test.describe.fixme('e2ee room setup', () => { +test.describe.serial('e2ee room setup', () => { let poAccountProfile: AccountProfile; let poHomeChannel: HomeChannel; let e2eePassword: string; @@ -1055,8 +1055,6 @@ test.describe.fixme('e2ee room setup', () => { await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); await poHomeChannel.btnRoomEnterE2EEPassword.waitFor(); @@ -1095,16 +1093,10 @@ test.describe.fixme('e2ee room setup', () => { const channelName = faker.string.uuid(); - await poHomeChannel.sidenav.openNewByLabel('Channel'); - await poHomeChannel.sidenav.inputChannelName.fill(channelName); - await poHomeChannel.sidenav.advancedSettingsAccordion.click(); - await poHomeChannel.sidenav.checkboxEncryption.click(); - await poHomeChannel.sidenav.btnCreate.click(); + await poHomeChannel.sidenav.createEncryptedChannel(channelName); await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.dismissToast(); - await expect(poHomeChannel.content.encryptedRoomHeaderIcon.first()).toBeVisible(); await poHomeChannel.content.sendMessage('hello world'); diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 8c33baaeda464..339a386b3a702 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -217,15 +217,15 @@ export default class E2EE { } storeRandomPassword(randomPassword: string): void { - localStorage.setItem('e2e.random_password', randomPassword); + localStorage.setItem('e2e.randomPassword', randomPassword); } getRandomPassword(): string | null { - return localStorage.getItem('e2e.random_password'); + return localStorage.getItem('e2e.randomPassword'); } removeRandomPassword(): void { - localStorage.removeItem('e2e.random_password'); + localStorage.removeItem('e2e.randomPassword'); } async createRandomPassword(length: number): Promise { const randomPassword = await generateMnemonicPhrase(length); From ea6a38948050f0a953ae3fa7ff883655eb5328b1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 17:56:58 +0000 Subject: [PATCH 090/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 899e8ec8e3671..263d99fdbd23b 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -4,6 +4,7 @@ - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) +- [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) From e62ef934563ca401c1f5a484e206cb08c988ef16 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 18:10:00 +0000 Subject: [PATCH 091/251] add more history --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- packages/e2ee/docs/NOTES.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 84818f2b1ec72..a4931c0b6ed25 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -708,7 +708,7 @@ export class E2ERoom extends Emitter { let oldKey = null; if (keyID !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key: any) => key.e2eKeyId === keyID); + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === keyID); // Messages already contain a keyID stored with them // That means that if we cannot find a keyID for the key the message has preppended to // The message is indecipherable. diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 263d99fdbd23b..cc0e3949bcc58 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -6,6 +6,7 @@ - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) +- [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) From fd8e0c855cd0eda26c242f46d10604def7de7c5b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 16:17:32 -0300 Subject: [PATCH 092/251] fix tests --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 10 ++++++---- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 6 +++--- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index a4931c0b6ed25..1a98111c6ea4d 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -388,13 +388,15 @@ export class E2ERoom extends Emitter { this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } + const jwk = JSON.parse(this.sessionKeyExportedString!); + // Import session key for use. try { - const key = await importAesGcmKey(JSON.parse(this.sessionKeyExportedString!)); + const key = await importAesGcmKey(jwk); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - this.error('Error importing group key: ', error); + this.error('Error importing group key: ', error, JSON.stringify(jwk)); return false; } @@ -534,10 +536,10 @@ export class E2ERoom extends Emitter { } } - async encryptGroupKeyForParticipant(publicKey: JsonWebKey) { + async encryptGroupKeyForParticipant(publicKey: string) { let userKey; try { - userKey = await importRSAKey(publicKey, ['encrypt']); + userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { return this.error('Error importing user key: ', error); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index c3970881f106d..d090ba7e262eb 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -54,7 +54,7 @@ class E2E extends Emitter<{ public privateKey: CryptoKey | undefined; - public publicKey: JsonWebKey | undefined; + public publicKey: string | undefined; private keyDistributionInterval: ReturnType | null; @@ -439,7 +439,7 @@ class E2E extends Emitter<{ } async loadKeys(keys: EncryptedKeyPair): Promise { - this.publicKey = JSON.parse(keys.public_key); + this.publicKey = keys.public_key; const res = await this.e2ee.loadKeys(keys); if (!res.isOk) { this.setState('ERROR'); @@ -459,7 +459,7 @@ class E2E extends Emitter<{ return logger.error('Error creating keys: ', keys.error); } - this.publicKey = keys.value.publicKey; + this.publicKey = JSON.stringify(keys.value.publicKey); this.privateKey = keys.value.privateKey; await this.requestSubscriptionKeys(); diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index a76c303282aa8..a0091c3a4d8e4 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -342,7 +342,7 @@ test.describe('basic features', () => { }); }); -test.describe.serial('e2e-encryption', () => { +test.describe('e2e-encryption', () => { let poHomeChannel: HomeChannel; test.use({ storageState: Users.userE2EE.state }); From a79e9fbec87ef8b9a8f0d0a2c79c2f5ee7d8208d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 19:55:31 +0000 Subject: [PATCH 093/251] add group key typings --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 9 ++++++--- packages/e2ee/src/__tests__/group.spec.ts | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 1a98111c6ea4d..46a5f833de30c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -67,6 +67,9 @@ const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERo return false; }; +export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; +export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; + export class E2ERoom extends Emitter { state: E2ERoomState | undefined = undefined; @@ -243,7 +246,7 @@ export class E2ERoom extends Emitter { return; } - const keys = []; + const keys: DecryptedGroupKey[] = []; for await (const key of sub.oldRoomKeys) { try { const k = await this.decryptSessionKey(key.E2EKey); @@ -506,7 +509,7 @@ export class E2ERoom extends Emitter { } } - async encryptOldKeysForParticipant(publicKey: JsonWebKey, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { + async encryptOldKeysForParticipant(publicKey: JsonWebKey, oldRoomKeys: EncryptedGroupKey[]) { if (!oldRoomKeys || oldRoomKeys.length === 0) { return; } @@ -520,7 +523,7 @@ export class E2ERoom extends Emitter { } try { - const keys = []; + const keys: EncryptedGroupKey[] = []; for await (const oldRoomKey of oldRoomKeys) { if (!oldRoomKey.E2EKey) { continue; diff --git a/packages/e2ee/src/__tests__/group.spec.ts b/packages/e2ee/src/__tests__/group.spec.ts index f2839e8499965..9d3757693da08 100644 --- a/packages/e2ee/src/__tests__/group.spec.ts +++ b/packages/e2ee/src/__tests__/group.spec.ts @@ -8,7 +8,6 @@ describe('EncryptionClient', () => { beforeEach(() => { // Reset the mock service and IndexedDB before each test mockService = new MockChatService(); - indexedDB.deleteDatabase('e2ee-chat-db'); }); describe('User Management', () => { From dfb073ef405a90c4c4c9fc4c5df96c7a48b7c147 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 20:58:36 +0000 Subject: [PATCH 094/251] remove usage of deprecated e2e.updateGroupKey --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 43 ++++++++++--------- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 9 ++-- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 46a5f833de30c..f415db696b1d8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -83,8 +83,6 @@ export class E2ERoom extends Emitter { typeOfRoom: string; - roomKeyId: string | undefined; - groupSessionKey: CryptoKey | undefined; oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; @@ -99,7 +97,6 @@ export class E2ERoom extends Emitter { this.userId = userId; this.roomId = room._id; this.typeOfRoom = room.t; - this.roomKeyId = room.e2eKeyId; this.once('READY', async () => { await this.decryptOldRoomKeys(); @@ -117,11 +114,11 @@ export class E2ERoom extends Emitter { } log(...msg: unknown[]) { - logger.log(`ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + logger.log(`[${this.roomId} (${this.state})]`, ...msg); } error(...msg: unknown[]) { - logger.error(`ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + logger.error(`[${this.roomId} (${this.state})]`, ...msg); } hasSessionKey() { @@ -266,14 +263,14 @@ export class E2ERoom extends Emitter { this.log('decryptOldRoomKeys Done'); } - async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { + async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']): Promise { this.log('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { this.log('exportOldRoomKeys nothing to do'); return; } - const keys = []; + const keys: EncryptedGroupKey[] = []; for await (const key of oldKeys) { try { if (!key.E2EKey) { @@ -369,7 +366,13 @@ export class E2ERoom extends Emitter { async importGroupKey(groupKey: string) { this.log('Importing room key ->', this.roomId); // Get existing group key - // const keyID = groupKey.slice(0, 12); + const keyID = groupKey.slice(0, 12); + + if (this.keyID === keyID) { + this.log('Group key is already imported'); + return true; + } + groupKey = groupKey.slice(12); const decodedGroupKey = Base64.decode(groupKey); @@ -388,7 +391,7 @@ export class E2ERoom extends Emitter { // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } const jwk = JSON.parse(this.sessionKeyExportedString!); @@ -420,13 +423,14 @@ export class E2ERoom extends Emitter { await this.createNewGroupKey(); await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); + this.keyID; const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); if (myKey) { - await sdk.rest.post('/v1/e2e.updateGroupKey', { - rid: this.roomId, - uid: this.userId, - key: myKey, - }); + // await sdk.rest.post('/v1/e2e.updateGroupKey', { + // rid: this.roomId, + // uid: this.userId, + // key: myKey, + // }); await this.encryptKeyForOtherParticipants(); } } catch (error) { @@ -488,13 +492,12 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const jwk = JSON.parse(user.e2e!.public_key!); - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(jwk); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); if (!encryptedGroupKey) { return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(jwk, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key!, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; @@ -509,7 +512,7 @@ export class E2ERoom extends Emitter { } } - async encryptOldKeysForParticipant(publicKey: JsonWebKey, oldRoomKeys: EncryptedGroupKey[]) { + async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { if (!oldRoomKeys || oldRoomKeys.length === 0) { return; } @@ -517,13 +520,13 @@ export class E2ERoom extends Emitter { let userKey; try { - userKey = await importRSAKey(publicKey, ['encrypt']); + userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { return this.error('Error importing user key: ', error); } try { - const keys: EncryptedGroupKey[] = []; + const keys = []; for await (const oldRoomKey of oldRoomKeys) { if (!oldRoomKey.E2EKey) { continue; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index a0091c3a4d8e4..49954cfe26f4a 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -19,7 +19,7 @@ import { import { ExportMessagesTab } from './page-objects/fragments/export-messages-tab'; import { FileUploadModal } from './page-objects/fragments/file-upload-modal'; import { LoginPage } from './page-objects/login'; -import { getSettingValueById } from './utils'; +import { deleteRoom, getSettingValueById } from './utils'; import { test, expect } from './utils/test'; const settings = { @@ -352,6 +352,7 @@ test.describe('e2e-encryption', () => { await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true }); await api.post('/settings/E2E_Enabled_Default_DirectRooms', { value: false }); await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: false }); + await api.post('/im.delete', { roomId: `user2${Users.userE2EE.data.username}` }); }); test.afterAll(async ({ api }) => { @@ -585,15 +586,13 @@ test.describe('e2e-encryption', () => { await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); await poHomeChannel.tabs.kebab.click({ force: true }); - - // Disable encryption if enabled if (await poHomeChannel.tabs.btnDisableE2E.isVisible()) { await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); + await expect(page.getByRole('dialog', { name: 'Disable encryption' })).toBeVisible(); await page.getByRole('button', { name: 'Disable encryption' }).click(); - await page.getByRole('button', { name: 'Dismiss alert' }).click(); + await poHomeChannel.dismissToast(); await poHomeChannel.tabs.kebab.click({ force: true }); } - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); From b9699f858804e302a57961d10b72a82646467f58 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 20:59:43 +0000 Subject: [PATCH 095/251] remove unused import --- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 49954cfe26f4a..60d34a90e0f45 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -19,7 +19,7 @@ import { import { ExportMessagesTab } from './page-objects/fragments/export-messages-tab'; import { FileUploadModal } from './page-objects/fragments/file-upload-modal'; import { LoginPage } from './page-objects/login'; -import { deleteRoom, getSettingValueById } from './utils'; +import { getSettingValueById } from './utils'; import { test, expect } from './utils/test'; const settings = { From 850912edf0e46bb7f973c188eacfcb0ab2050a30 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 21:37:34 +0000 Subject: [PATCH 096/251] fix linting errors --- packages/e2ee/.oxlintrc.json | 3 ++- packages/e2ee/src/base64.ts | 1 + packages/e2ee/src/group.ts | 39 ++++++++++++++++++++--------------- packages/e2ee/src/keychain.ts | 23 ++++++++++++++++----- 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json index e91e398aebf87..25081681a8410 100644 --- a/packages/e2ee/.oxlintrc.json +++ b/packages/e2ee/.oxlintrc.json @@ -25,6 +25,7 @@ "eslint/max-classes-per-file": "allow", "no-extraneous-class": "allow", "no-console": "allow", - "no-rest-spread-properties": "allow" + "no-rest-spread-properties": "allow", + "typescript/unbound-method": "allow" } } \ No newline at end of file diff --git a/packages/e2ee/src/base64.ts b/packages/e2ee/src/base64.ts index 3773c007d947b..b218eea81efbd 100644 --- a/packages/e2ee/src/base64.ts +++ b/packages/e2ee/src/base64.ts @@ -1,4 +1,5 @@ const { fromBase64 } = globalThis.Uint8Array; +/* oxlint-disable-next-line typescript/unbound-method */ const { toBase64 } = globalThis.Uint8Array.prototype; export const fromB64Fallback = (string: string): Uint8Array => { diff --git a/packages/e2ee/src/group.ts b/packages/e2ee/src/group.ts index 8adb0edb66e9c..28dee9e9243cb 100644 --- a/packages/e2ee/src/group.ts +++ b/packages/e2ee/src/group.ts @@ -113,24 +113,24 @@ export class EncryptionClient { const allMemberIds = [...new Set([this.userId, ...memberUserIds])]; const groupKey = await generateAesKey(); - const encryptedKeyInfos: EncryptedGroupKeyInfo[] = []; - const publicKeyInfos = await this.chatService.findUsersPublicKeys(allMemberIds); - - for await (const memberId of allMemberIds) { - const publicKeyInfo = publicKeyInfos.get(memberId); - if (!publicKeyInfo) { - throw new Error(`Could not find public key for user ${memberId}`); - } - const memberPublicKey = await importPublicKey(publicKeyInfo); - const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); + const publicKeyInfos = await this.chatService.findUsersPublicKeys(allMemberIds); - encryptedKeyInfos.push({ - userId: memberId, - encryptedKey: encryptedGroupKey, - keyAlgorithm: publicKeyInfo.algorithm, - }); - } + const encryptedKeyInfos: EncryptedGroupKeyInfo[] = await Promise.all( + allMemberIds.map(async (memberId) => { + const publicKeyInfo = publicKeyInfos.get(memberId); + if (!publicKeyInfo) { + throw new Error(`Could not find public key for user ${memberId}`); + } + const memberPublicKey = await importPublicKey(publicKeyInfo); + const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); + return { + userId: memberId, + encryptedKey: encryptedGroupKey, + keyAlgorithm: publicKeyInfo.algorithm, + }; + }), + ); await this.chatService.createGroup(groupId, encryptedKeyInfos); this.groupKeys.set(groupId, groupKey); @@ -260,7 +260,12 @@ export class EncryptionClient { const encryptedMessages = await this.chatService.getMessages(groupId); // Decrypt all messages in parallel - const decryptedMessages = await Promise.all(encryptedMessages.map((msg) => aesDecrypt(msg, groupKey))); + const decryptedMessages = await Promise.all( + encryptedMessages.map(async (msg) => { + const decryptedMessage = await aesDecrypt(msg, groupKey); + return decryptedMessage; + }), + ); return decryptedMessages; } diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts index 89c342b33ac89..cadc6d2fe0071 100644 --- a/packages/e2ee/src/keychain.ts +++ b/packages/e2ee/src/keychain.ts @@ -13,7 +13,9 @@ export class Keychain { public static async init(userId: string): Promise { const db = await new Promise((resolve, reject) => { const request = indexedDB.open(`E2eeChatDB`, 1); - request.onerror = (): void => reject(request.error); + request.onerror = (): void => { + reject(request.error ?? new Error('Unknown error')); + }; request.onsuccess = (): void => { resolve(request.result); }; @@ -61,9 +63,16 @@ export class Keychain { const store = transaction.objectStore('CryptoKeys'); const request = store.get(userId); request.onsuccess = (): void => { - resolve(request.result ? request.result.privateKey : false); + const result = request.result as unknown; + if (typeof result === 'object' && result !== null && 'privateKey' in result && result.privateKey instanceof CryptoKey) { + resolve(result.privateKey); + } else { + resolve(false); + } + }; + request.onerror = (): void => { + reject(new Error('Failed to get private key', { cause: request.error })); }; - request.onerror = (): void => reject(new Error('Failed to get private key', { cause: request.error })); }); return privateKey; @@ -76,8 +85,12 @@ export class Keychain { const transaction = db.transaction('CryptoKeys', 'readwrite'); const store = transaction.objectStore('CryptoKeys'); const request = store.delete(userId); - request.onsuccess = (): void => resolve(); - request.onerror = (): void => reject(new Error('Failed to delete private key', { cause: request.error })); + request.onsuccess = (): void => { + resolve(); + }; + request.onerror = (): void => { + reject(new Error('Failed to delete private key', { cause: request.error })); + }; }); } } From f0ffbb3533ae73ec54d0be4086ee272d268dfca1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 21:44:44 +0000 Subject: [PATCH 097/251] use rest api instead of method call --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index f415db696b1d8..f82021e6f010e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -422,15 +422,9 @@ export class E2ERoom extends Emitter { try { await this.createNewGroupKey(); - await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - this.keyID; + await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID }); const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); if (myKey) { - // await sdk.rest.post('/v1/e2e.updateGroupKey', { - // rid: this.roomId, - // uid: this.userId, - // key: myKey, - // }); await this.encryptKeyForOtherParticipants(); } } catch (error) { From bbe89ec9626dba6694770ea2c96e86d220acec84 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 21:51:37 +0000 Subject: [PATCH 098/251] remove dead code --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 8 -------- packages/e2ee/docs/NOTES.md | 1 + 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index f82021e6f010e..7409c448b54e9 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -590,14 +590,6 @@ export class E2ERoom extends Emitter { }; } - // Decrypt uploaded encrypted files. I/O is in arraybuffers. - async decryptFile(file: Uint8Array, key: JsonWebKey, iv: string) { - const ivArray = Base64.decode(iv); - const cryptoKey = await window.crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']); - - return window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file); - } - // Encrypts messages async encryptText(data: Uint8Array) { const vector = crypto.getRandomValues(new Uint8Array(16)); diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index cc0e3949bcc58..e0220ac69a094 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -7,6 +7,7 @@ - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) +- [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) From fdaa5715e5078487cda4884ac342408de18a0f95 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 21:53:55 +0000 Subject: [PATCH 099/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index e0220ac69a094..f342dfabbd01d 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -2,6 +2,7 @@ ## History - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) +- [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) From 7534a3c8e3d1536b91e901c40e004415eeff7e6a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 22:06:44 +0000 Subject: [PATCH 100/251] use rest api for getUsersOfRoomWithoutKey --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- packages/e2ee/docs/NOTES.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 7409c448b54e9..64d3a95d2f180 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -471,7 +471,7 @@ export class E2ERoom extends Emitter { try { const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); - const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); + const { users } = await sdk.rest.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid: this.roomId }); if (!users.length) { return; diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index f342dfabbd01d..04600f473bc2a 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -9,6 +9,7 @@ - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) - [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) +- [regression(E2EEncryption): Service Worker not installing on first load](https://github.com/RocketChat/Rocket.Chat/pull/32674) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) From 328bf15db4d09d2c28859771021b5d4eaa4efd73 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 22:16:06 +0000 Subject: [PATCH 101/251] add some type annotations --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 64d3a95d2f180..b2a6d8b8f61ad 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -85,7 +85,7 @@ export class E2ERoom extends Emitter { groupSessionKey: CryptoKey | undefined; - oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; + oldKeys: DecryptedGroupKey[] | undefined; sessionKeyExportedString: string | undefined; @@ -113,23 +113,23 @@ export class E2ERoom extends Emitter { this.setState('NOT_STARTED'); } - log(...msg: unknown[]) { + log(...msg: unknown[]): void { logger.log(`[${this.roomId} (${this.state})]`, ...msg); } - error(...msg: unknown[]) { + error(...msg: unknown[]): void { logger.error(`[${this.roomId} (${this.state})]`, ...msg); } - hasSessionKey() { + hasSessionKey(): boolean { return !!this.groupSessionKey; } - getState() { + getState(): E2ERoomState | undefined { return this.state; } - setState(requestedState: E2ERoomState) { + setState(requestedState: E2ERoomState): void { const currentState = this.state; const nextState = filterMutation(currentState, requestedState); @@ -301,7 +301,7 @@ export class E2ERoom extends Emitter { } // Initiates E2E Encryption - async handshake() { + async handshake(): Promise { if (!e2e.isReady()) { return; } @@ -343,15 +343,15 @@ export class E2ERoom extends Emitter { } } - isSupportedRoomType(type: string) { + isSupportedRoomType(type: string): boolean { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } - async decryptSessionKey(key: string) { + async decryptSessionKey(key: string): Promise { return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); } - async exportSessionKey(key: string) { + async exportSessionKey(key: string): Promise { key = key.slice(12); const decodedKey = Base64.decode(key); @@ -363,7 +363,7 @@ export class E2ERoom extends Emitter { return toString(decryptedKey); } - async importGroupKey(groupKey: string) { + async importGroupKey(groupKey: string): Promise { this.log('Importing room key ->', this.roomId); // Get existing group key const keyID = groupKey.slice(0, 12); From c2befab0b03138e03f47729abf767d998ba8aa71 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 22:16:25 +0000 Subject: [PATCH 102/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 04600f473bc2a..ad4af86d0a572 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -4,6 +4,7 @@ - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) +- [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) From 13d94cf7af423144d7d9219be3d640f4a8047241 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 2 Sep 2025 22:18:49 +0000 Subject: [PATCH 103/251] fix warnings --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index b2a6d8b8f61ad..f16139aa366a4 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -394,7 +394,7 @@ export class E2ERoom extends Emitter { this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } - const jwk = JSON.parse(this.sessionKeyExportedString!); + const jwk = JSON.parse(this.sessionKeyExportedString); // Import session key for use. try { @@ -486,12 +486,12 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key); if (!encryptedGroupKey) { return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key!, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; From 2677e2efb54c8dff406e4b8156e9318480fd9dba Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 11:22:22 -0300 Subject: [PATCH 104/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index ad4af86d0a572..b525f34583c70 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -15,6 +15,7 @@ - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) +- [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) ## Tasks From cd67e17c2f3d244ad98a6646b343f54e53eb203c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 11:23:03 -0300 Subject: [PATCH 105/251] bip39 --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 6 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 21 +- .../root/hooks/loggedIn/useE2EEncryption.ts | 12 +- packages/e2ee/src/index.ts | 22 +- .../src/{word-list.ts => wordlists/v1.ts} | 2 +- packages/e2ee/src/wordlists/v2.ts | 2048 +++++++++++++++++ yarn.lock | 2 +- 7 files changed, 2081 insertions(+), 32 deletions(-) rename packages/e2ee/src/{word-list.ts => wordlists/v1.ts} (99%) create mode 100644 packages/e2ee/src/wordlists/v2.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index f16139aa366a4..c64da99203f13 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -71,9 +71,9 @@ export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; export class E2ERoom extends Emitter { - state: E2ERoomState | undefined = undefined; + state: E2ERoomState = 'NOT_STARTED'; - [PAUSED]: boolean | undefined = undefined; + [PAUSED] = false; [KEY_ID]: string; @@ -109,8 +109,6 @@ export class E2ERoom extends Emitter { } }); this.on('STATE_CHANGED', () => this.handshake()); - - this.setState('NOT_STARTED'); } log(...msg: unknown[]): void { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index d090ba7e262eb..040140d6a5541 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,7 +3,7 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { EncryptedKeyPair, LocalKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; +import type { EncryptedKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; import E2EE from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; @@ -58,7 +58,7 @@ class E2E extends Emitter<{ private keyDistributionInterval: ReturnType | null; - private state: E2EEState; + private state: E2EEState = 'NOT_STARTED'; private e2ee: E2EE; @@ -100,8 +100,6 @@ class E2E extends Emitter<{ this.on('ERROR', () => { this.unsubscribeFromSubscriptions?.(); }); - - this.setState('NOT_STARTED'); } getState() { @@ -288,13 +286,6 @@ class E2E extends Emitter<{ delete this.instancesByRoomId[rid]; } - private async persistKeys(localKeyPair: LocalKeyPair, password: string, force = false): Promise { - const res = await this.e2ee.persistKeys(localKeyPair, password, force); - if (!res.isOk) { - throw res.error; - } - } - async acceptSuggestedKey(rid: string): Promise { await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { rid, @@ -385,7 +376,7 @@ class E2E extends Emitter<{ if (!this.db_public_key || !this.db_private_key) { this.setState('LOADING_KEYS'); - await this.persistKeys(this.e2ee.getKeysFromLocalStorage(), await this.e2ee.createRandomPassword(5)); + await this.e2ee.backupKeys(); } const randomPassword = this.e2ee.getRandomPassword(); @@ -417,11 +408,7 @@ class E2E extends Emitter<{ } async changePassword(newPassword: string): Promise { - await this.persistKeys(await this.e2ee.getKeysFromLocalStorage(), newPassword, true); - - if (await this.e2ee.getRandomPassword()) { - await this.e2ee.storeRandomPassword(newPassword); - } + await this.e2ee.changePassphrase(newPassword); } async loadKeysFromDB(): Promise { diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 76c06f0d59afa..9eee3e1f93d05 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -19,7 +19,7 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - logger.log('Not logged in'); + logger.warn('Not logged in'); return; } @@ -29,7 +29,7 @@ export const useE2EEncryption = () => { } if (enabled && !adminEmbedded) { - logger.log('enabled starting client'); + logger.debug('enabled starting client'); e2e.startClient(); } else { e2e.setState('DISABLED'); @@ -48,12 +48,12 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - logger.log('Not ready'); + logger.info('Not ready'); return; } if (listenersAttachedRef.current) { - logger.log('Listeners already attached'); + logger.warn('Listeners already attached'); return; } @@ -120,10 +120,10 @@ export const useE2EEncryption = () => { }); listenersAttachedRef.current = true; - logger.log('Listeners attached'); + logger.info('Listeners attached'); return () => { - logger.log('Not ready'); + logger.info('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 339a386b3a702..f52a018fd7310 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -12,9 +12,9 @@ import { /** decryptAesCbc, encryptAesCbc, **/ encryptAesGcm, decryptAesGcm } fr // import { Keychain } from './keychain.ts'; const generateMnemonicPhrase = async (length: number): Promise => { - const { v1 } = await import('./word-list.ts'); + const { wordlist } = await import('./wordlists/v2.ts'); const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); - return Array.from(randomBuffer, (value) => v1[value % v1.length]).join(' '); + return Array.from(randomBuffer, (value) => wordlist[value % wordlist.length]).join(' '); }; export interface KeyService { @@ -91,7 +91,23 @@ export default class E2EE { } } - async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { + async backupKeys(): Promise { + await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword(5), false); + } + + async changePassphrase(newPassword: string): Promise { + const result = await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, true); + + if (!result.isOk) { + throw result.error; + } + + if (this.getRandomPassword()) { + this.storeRandomPassword(newPassword); + } + } + + private async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { return err(new TypeError('Failed to persist keys as they are not strings.')); } diff --git a/packages/e2ee/src/word-list.ts b/packages/e2ee/src/wordlists/v1.ts similarity index 99% rename from packages/e2ee/src/word-list.ts rename to packages/e2ee/src/wordlists/v1.ts index 8386533d6d424..abc53dd4bd3a0 100644 --- a/packages/e2ee/src/word-list.ts +++ b/packages/e2ee/src/wordlists/v1.ts @@ -1,4 +1,4 @@ -export const v1: string[] = `acrobat +export const wordlist: string[] = `acrobat africa alaska albert diff --git a/packages/e2ee/src/wordlists/v2.ts b/packages/e2ee/src/wordlists/v2.ts new file mode 100644 index 0000000000000..2cfea1f1e8457 --- /dev/null +++ b/packages/e2ee/src/wordlists/v2.ts @@ -0,0 +1,2048 @@ +export const wordlist: string[] = `abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo`.split('\n'); diff --git a/yarn.lock b/yarn.lock index 0de51741ead97..c64253b6d0914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9587,7 +9587,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 - "@rocket.chat/ui-contexts": 22.0.0-rc.6 + "@rocket.chat/ui-contexts": 22.0.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*" From 6239bd92a24fab222a84e3cda724343de0c28b22 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 11:26:14 -0300 Subject: [PATCH 106/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index b525f34583c70..bebd4ef77880b 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -5,6 +5,7 @@ - [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) +- [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) From ec2bdfdebd229e73a17441d23f92b76085800376 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 11:36:03 -0300 Subject: [PATCH 107/251] add more history --- packages/e2ee/docs/NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index bebd4ef77880b..35c01fd94f14d 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -5,6 +5,7 @@ - [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) +- [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) - [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) @@ -12,6 +13,7 @@ - [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) - [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) - [regression(E2EEncryption): Service Worker not installing on first load](https://github.com/RocketChat/Rocket.Chat/pull/32674) +- [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) From 67910bc7596b7e59577955b488b95ccf31063099 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 11:38:30 -0300 Subject: [PATCH 108/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 35c01fd94f14d..a3f0b2c56ae6e 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -18,6 +18,7 @@ - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) +- [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) - [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) ## Tasks From a096b7d7e83af72fec03e2bf08aa066c3d43e553 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 12:25:27 -0300 Subject: [PATCH 109/251] add more history --- packages/e2ee/docs/NOTES.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index a3f0b2c56ae6e..76a88ba2e7e20 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -9,6 +9,7 @@ - [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) +- [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) - [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) @@ -16,10 +17,14 @@ - [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) -- [feat: E2EE rom key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) +- [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) +- [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) +- [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) - [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) +- [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) - [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) +- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) ## Tasks From b5700450baa50031e869561c3bab84db981bf757 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 13:53:18 -0300 Subject: [PATCH 110/251] add more history --- packages/e2ee/docs/NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 76a88ba2e7e20..698a906e443e4 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -2,7 +2,18 @@ ## History - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) +- [Fix: Change wording on e2e to make a little more clear](https://github.com/RocketChat/Rocket.Chat/pull/12124) +- [Fix: e2e password visible on always-on alert message](https://github.com/RocketChat/Rocket.Chat/pull/12139) +- [New: Option to change E2E key](https://github.com/RocketChat/Rocket.Chat/pull/12169) +- [Improve: Moved the e2e password request to an alert instead of a popup](https://github.com/RocketChat/Rocket.Chat/pull/12172) - [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) +- [Improve: Rename E2E methods](https://github.com/RocketChat/Rocket.Chat/pull/12175) +- [Improve: Do not start E2E Encryption when accessing admin as embedded](https://github.com/RocketChat/Rocket.Chat/pull/12192) +- [[FIX] E2E data not cleared on logout](https://github.com/RocketChat/Rocket.Chat/pull/12254) +- [[NEW] Option to reset e2e key](https://github.com/RocketChat/Rocket.Chat/pull/12483) +- [Remove directly dependency between lib and e2e](https://github.com/RocketChat/Rocket.Chat/pull/13115) +- [[FIX] E2E messages not decrypting in message threads](https://github.com/RocketChat/Rocket.Chat/pull/14580) +- [[FIX] Not possible to read encrypted messages after disable E2E on channel level](https://github.com/RocketChat/Rocket.Chat/pull/18101) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) - [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) - [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) From 1f0ef12c4e374819c4b678365a719ddbe6b0b382 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 14:12:56 -0300 Subject: [PATCH 111/251] simplify key agreement --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 37 ++++++++++---- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 50 ++++++------------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index c64da99203f13..6243e0af8e9d8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -109,6 +109,8 @@ export class E2ERoom extends Emitter { } }); this.on('STATE_CHANGED', () => this.handshake()); + + this.handshake(); } log(...msg: unknown[]): void { @@ -123,7 +125,7 @@ export class E2ERoom extends Emitter { return !!this.groupSessionKey; } - getState(): E2ERoomState | undefined { + getState(): E2ERoomState { return this.state; } @@ -158,10 +160,6 @@ export class E2ERoom extends Emitter { this.setState('READY'); } - disable() { - this.setState('DISABLED'); - } - pause() { this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; @@ -174,10 +172,6 @@ export class E2ERoom extends Emitter { this.emit('PAUSED', false); } - keyReceived() { - this.setState('KEYS_RECEIVED'); - } - async shouldConvertSentMessages(message: { msg: string }) { if (!this.isReady() || this[PAUSED]) { return false; @@ -298,6 +292,18 @@ export class E2ERoom extends Emitter { ); } + async acceptSuggestedKey(): Promise { + await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { + rid: this.roomId, + }); + } + + async rejectSuggestedKey(): Promise { + await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { + rid: this.roomId, + }); + } + // Initiates E2E Encryption async handshake(): Promise { if (!e2e.isReady()) { @@ -361,6 +367,19 @@ export class E2ERoom extends Emitter { return toString(decryptedKey); } + async handleSuggestedKey(suggestedKey: string, encrypted?: boolean) { + if (await this.importGroupKey(suggestedKey)) { + logger.log('Imported valid E2E suggested key'); + await this.acceptSuggestedKey(); + this.setState('KEYS_RECEIVED'); + } else { + logger.error('Invalid E2ESuggestedKey, rejecting', suggestedKey); + await this.rejectSuggestedKey(); + } + + encrypted ? this.resume() : this.pause(); + } + async importGroupKey(groupKey: string): Promise { this.log('Importing room key ->', this.roomId); // Get existing group key diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 040140d6a5541..baef301082017 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -141,11 +141,11 @@ class E2E extends Emitter<{ if (sub.E2ESuggestedKey) { if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { - await this.acceptSuggestedKey(sub.rid); - e2eRoom.keyReceived(); + await e2eRoom.acceptSuggestedKey(); + e2eRoom.setState('KEYS_RECEIVED'); } else { logger.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); - await this.rejectSuggestedKey(sub.rid); + await e2eRoom.rejectSuggestedKey(); } } @@ -153,12 +153,12 @@ class E2E extends Emitter<{ // Cover private groups and direct messages if (!e2eRoom.isSupportedRoomType(sub.t)) { - e2eRoom.disable(); + e2eRoom.setState('DISABLED'); return; } if (sub.E2EKey && e2eRoom.isWaitingKeys()) { - e2eRoom.keyReceived(); + e2eRoom.setState('KEYS_RECEIVED'); return; } @@ -208,6 +208,13 @@ class E2E extends Emitter<{ this.emit(nextState); } + async room(rid: IRoom['_id'], fn: (room: E2ERoom) => Promise) { + const e2eRoom = await this.getInstanceByRoomId(rid); + if (e2eRoom) { + await fn(e2eRoom); + } + } + /** * Handles the suggested E2E key for a subscription. */ @@ -215,24 +222,11 @@ class E2E extends Emitter<{ const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( subs - .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) + .flatMap(({ E2ESuggestedKey, E2EKey, rid, encrypted }) => (E2ESuggestedKey && !E2EKey ? [{ rid, E2ESuggestedKey, encrypted }] : [])) .map(async (sub) => { - const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); - - if (!e2eRoom) { - return; - } - - if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - logger.log('Imported valid E2E suggested key'); - await e2e.acceptSuggestedKey(sub.rid); - e2eRoom.keyReceived(); - } else { - logger.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); - await e2e.rejectSuggestedKey(sub.rid); - } - - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + await this.room(sub.rid, async (e2eRoom) => { + await e2eRoom?.handleSuggestedKey(sub.E2ESuggestedKey, sub.encrypted); + }); }), ); } @@ -286,18 +280,6 @@ class E2E extends Emitter<{ delete this.instancesByRoomId[rid]; } - async acceptSuggestedKey(rid: string): Promise { - await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { - rid, - }); - } - - async rejectSuggestedKey(rid: string): Promise { - await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { - rid, - }); - } - initiateHandshake() { Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake()); } From 970359f2b9840872d64a010ffa3a2ac257175128 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 14:30:01 -0300 Subject: [PATCH 112/251] simplify subscription changed --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 4 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 49 ++++++++----------- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 6243e0af8e9d8..dc2af095403e8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -367,7 +367,7 @@ export class E2ERoom extends Emitter { return toString(decryptedKey); } - async handleSuggestedKey(suggestedKey: string, encrypted?: boolean) { + async handleSuggestedKey(suggestedKey: string) { if (await this.importGroupKey(suggestedKey)) { logger.log('Imported valid E2E suggested key'); await this.acceptSuggestedKey(); @@ -376,8 +376,6 @@ export class E2ERoom extends Emitter { logger.error('Invalid E2ESuggestedKey, rejecting', suggestedKey); await this.rejectSuggestedKey(); } - - encrypted ? this.resume() : this.pause(); } async importGroupKey(groupKey: string): Promise { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index baef301082017..8c3a83823f9a8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -134,39 +134,29 @@ class E2E extends Emitter<{ return; } - const e2eRoom = await this.getInstanceByRoomId(sub.rid); - if (!e2eRoom) { - return; - } - - if (sub.E2ESuggestedKey) { - if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { - await e2eRoom.acceptSuggestedKey(); - e2eRoom.setState('KEYS_RECEIVED'); - } else { - logger.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); - await e2eRoom.rejectSuggestedKey(); + await this.room(sub.rid, async (e2eRoom) => { + if (sub.E2ESuggestedKey) { + await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); } - } - - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - // Cover private groups and direct messages - if (!e2eRoom.isSupportedRoomType(sub.t)) { - e2eRoom.setState('DISABLED'); - return; - } + // Cover private groups and direct messages + if (!e2eRoom.isSupportedRoomType(sub.t)) { + e2eRoom.setState('DISABLED'); + return; + } - if (sub.E2EKey && e2eRoom.isWaitingKeys()) { - e2eRoom.setState('KEYS_RECEIVED'); - return; - } + if (sub.E2EKey && e2eRoom.isWaitingKeys()) { + e2eRoom.setState('KEYS_RECEIVED'); + return; + } - if (!e2eRoom.isReady()) { - return; - } + if (!e2eRoom.isReady()) { + return; + } - await e2eRoom.decryptSubscription(); + await e2eRoom.decryptSubscription(); + }); } private unsubscribeFromSubscriptions: (() => void) | undefined; @@ -225,7 +215,8 @@ class E2E extends Emitter<{ .flatMap(({ E2ESuggestedKey, E2EKey, rid, encrypted }) => (E2ESuggestedKey && !E2EKey ? [{ rid, E2ESuggestedKey, encrypted }] : [])) .map(async (sub) => { await this.room(sub.rid, async (e2eRoom) => { - await e2eRoom?.handleSuggestedKey(sub.E2ESuggestedKey, sub.encrypted); + await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); }); }), ); From b7abc67bb29319c94ce43944de43dc801af06878 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 15:13:57 -0300 Subject: [PATCH 113/251] move subscription changed to room --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 24 +++++++++++++++++++ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 23 +----------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index dc2af095403e8..a78333c2a0246 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -367,6 +367,30 @@ export class E2ERoom extends Emitter { return toString(decryptedKey); } + async handleSubscriptionChanged(sub: ISubscription) { + if (sub.E2ESuggestedKey) { + await this.handleSuggestedKey(sub.E2ESuggestedKey); + } + sub.encrypted ? this.resume() : this.pause(); + + // Cover private groups and direct messages + if (!this.isSupportedRoomType(sub.t)) { + this.setState('DISABLED'); + return; + } + + if (sub.E2EKey && this.isWaitingKeys()) { + this.setState('KEYS_RECEIVED'); + return; + } + + if (!this.isReady()) { + return; + } + + await this.decryptSubscription(); + } + async handleSuggestedKey(suggestedKey: string) { if (await this.importGroupKey(suggestedKey)) { logger.log('Imported valid E2E suggested key'); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 8c3a83823f9a8..fbe5ab0e106aa 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -135,27 +135,7 @@ class E2E extends Emitter<{ } await this.room(sub.rid, async (e2eRoom) => { - if (sub.E2ESuggestedKey) { - await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); - } - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - - // Cover private groups and direct messages - if (!e2eRoom.isSupportedRoomType(sub.t)) { - e2eRoom.setState('DISABLED'); - return; - } - - if (sub.E2EKey && e2eRoom.isWaitingKeys()) { - e2eRoom.setState('KEYS_RECEIVED'); - return; - } - - if (!e2eRoom.isReady()) { - return; - } - - await e2eRoom.decryptSubscription(); + await e2eRoom.handleSubscriptionChanged(sub); }); } @@ -176,7 +156,6 @@ class E2E extends Emitter<{ excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } - logger.table(subscriptions); for (const sub of subscriptions) { void this.onSubscriptionChanged(sub); } From 8ab94c374044c3f62eae40a888ef797f8f465c98 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 15:19:02 -0300 Subject: [PATCH 114/251] remove dead code --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index a78333c2a0246..a54667e6e3085 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -775,15 +775,6 @@ export class E2ERoom extends Emitter { } } - provideKeyToUser(keyId: string) { - if (this.keyID !== keyId) { - return; - } - - void this.encryptKeyForOtherParticipants(); - this.setState('READY'); - } - onStateChange(cb: () => void) { this.on('STATE_CHANGED', cb); return () => this.off('STATE_CHANGED', cb); From 00878c3b82eedfc91a6b12e4da7c867549d841fa Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 15:42:32 -0300 Subject: [PATCH 115/251] fix formatting --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index a54667e6e3085..b9daf0bc41a90 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -369,28 +369,28 @@ export class E2ERoom extends Emitter { async handleSubscriptionChanged(sub: ISubscription) { if (sub.E2ESuggestedKey) { - await this.handleSuggestedKey(sub.E2ESuggestedKey); - } - sub.encrypted ? this.resume() : this.pause(); - - // Cover private groups and direct messages - if (!this.isSupportedRoomType(sub.t)) { - this.setState('DISABLED'); - return; - } + await this.handleSuggestedKey(sub.E2ESuggestedKey); + } + sub.encrypted ? this.resume() : this.pause(); - if (sub.E2EKey && this.isWaitingKeys()) { - this.setState('KEYS_RECEIVED'); - return; - } + // Cover private groups and direct messages + if (!this.isSupportedRoomType(sub.t)) { + this.setState('DISABLED'); + return; + } - if (!this.isReady()) { - return; - } + if (sub.E2EKey && this.isWaitingKeys()) { + this.setState('KEYS_RECEIVED'); + return; + } - await this.decryptSubscription(); + if (!this.isReady()) { + return; } + await this.decryptSubscription(); + } + async handleSuggestedKey(suggestedKey: string) { if (await this.importGroupKey(suggestedKey)) { logger.log('Imported valid E2E suggested key'); From 74be31fb05da73ef09b6c51216378a1932d2cde8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 15:53:33 -0300 Subject: [PATCH 116/251] hoist key export --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index b9daf0bc41a90..c1e14b77794cd 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -67,6 +67,22 @@ const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERo return false; }; +const exportSessionKey = async (key: string): Promise => { + key = key.slice(12); + const decodedKey = Base64.decode(key); + + if (!e2e.privateKey) { + throw new Error('Private key not found'); + } + + const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + return toString(decryptedKey); + } + +const decryptSessionKey = async (key: string): Promise => { + return importAesGcmKey(JSON.parse(await exportSessionKey(key))); + } + export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; @@ -238,7 +254,7 @@ export class E2ERoom extends Emitter { const keys: DecryptedGroupKey[] = []; for await (const key of sub.oldRoomKeys) { try { - const k = await this.decryptSessionKey(key.E2EKey); + const k = await decryptSessionKey(key.E2EKey); keys.push({ ...key, E2EKey: k, @@ -269,7 +285,7 @@ export class E2ERoom extends Emitter { continue; } - const k = await this.exportSessionKey(key.E2EKey); + const k = await exportSessionKey(key.E2EKey); keys.push({ ...key, E2EKey: k, @@ -351,21 +367,9 @@ export class E2ERoom extends Emitter { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } - async decryptSessionKey(key: string): Promise { - return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); - } - - async exportSessionKey(key: string): Promise { - key = key.slice(12); - const decodedKey = Base64.decode(key); - - if (!e2e.privateKey) { - throw new Error('Private key not found'); - } + - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); - return toString(decryptedKey); - } + async handleSubscriptionChanged(sub: ISubscription) { if (sub.E2ESuggestedKey) { @@ -610,7 +614,6 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - console.log(error); return this.error('Error encrypting group key: ', error); } From 83a5870d14125388888da18d99141da0081f749b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 16:00:38 -0300 Subject: [PATCH 117/251] don't ignore error --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index c1e14b77794cd..3b37555b6a7e7 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -293,6 +293,7 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + e ); } } From 135a8a4b1957b52728d84e305079dda13a883d28 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 16:09:01 -0300 Subject: [PATCH 118/251] hoist wait for room --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index fbe5ab0e106aa..c154519b0776a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -34,6 +34,21 @@ let failedToDecodeKey = false; const ROOM_KEY_EXCHANGE_SIZE = 10; +const waitForRoom = (rid: IRoom['_id']): Promise => + new Promise((resolve) => { + const room = Rooms.state.get(rid); + + if (room) resolve(room); + + const unsubscribe = Rooms.use.subscribe((state) => { + const room = state.get(rid); + if (room) { + unsubscribe(); + resolve(room); + } + }); + }); + class E2E extends Emitter<{ READY: void; E2E_STATE_CHANGED: { prevState: E2EEState; nextState: E2EEState }; @@ -201,24 +216,8 @@ class E2E extends Emitter<{ ); } - private waitForRoom(rid: IRoom['_id']): Promise { - return new Promise((resolve) => { - const room = Rooms.state.get(rid); - - if (room) resolve(room); - - const unsubscribe = Rooms.use.subscribe((state) => { - const room = state.get(rid); - if (room) { - unsubscribe(); - resolve(room); - } - }); - }); - } - async getInstanceByRoomId(rid: IRoom['_id']): Promise { - const room = await this.waitForRoom(rid); + const room = await waitForRoom(rid); if (room.t !== 'd' && room.t !== 'p') { return null; From 91394a167aa27bab6fd8a130c7ce53a28f9f1a64 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 17:15:00 -0300 Subject: [PATCH 119/251] remove dead code --- .../hooks/roomActions/useE2EERoomAction.ts | 1 - apps/meteor/client/lib/e2ee/E2ERoomState.ts | 2 - .../client/lib/e2ee/rocketchat.e2e.room.ts | 64 ++++++------------- packages/e2ee/docs/NOTES.md | 1 + 4 files changed, 22 insertions(+), 46 deletions(-) diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index ffe39ff47e987..b3d4edecbd4c6 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -33,7 +33,6 @@ export const useE2EERoomAction = () => { const isE2EERoomNotReady = () => { if ( - e2eeRoomState === 'NO_PASSWORD_SET' || e2eeRoomState === 'NOT_STARTED' || e2eeRoomState === 'DISABLED' || e2eeRoomState === 'ERROR' || diff --git a/apps/meteor/client/lib/e2ee/E2ERoomState.ts b/apps/meteor/client/lib/e2ee/E2ERoomState.ts index e084d0b6d9a07..bb2d36ff32110 100644 --- a/apps/meteor/client/lib/e2ee/E2ERoomState.ts +++ b/apps/meteor/client/lib/e2ee/E2ERoomState.ts @@ -1,8 +1,6 @@ export type E2ERoomState = - | 'NO_PASSWORD_SET' | 'NOT_STARTED' | 'DISABLED' - | 'HANDSHAKE' | 'ESTABLISHING' | 'CREATING_KEYS' | 'WAITING_KEYS' diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 3b37555b6a7e7..b5984c05e165e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -36,52 +36,46 @@ import { roomCoordinator } from '../rooms/roomCoordinator'; const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { [k in E2ERoomState]?: E2ERoomState[] }; +type Mutations = { [K in E2ERoomState]: E2ERoomState[] }; const permitedMutations: Mutations = { NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'], + ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], + KEYS_RECEIVED: ['ESTABLISHING'], + CREATING_KEYS: ['READY', 'KEYS_RECEIVED'], READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], - ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'], + ERROR: ['KEYS_RECEIVED', 'NOT_STARTED', 'ERROR'], WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], - ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], + DISABLED: [], }; -const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { - // When state is undefined, allow it to be moved - if (!currentState) { - return nextState; - } - +const filterMutation = (currentState: E2ERoomState, nextState: E2ERoomState): E2ERoomState | false => { if (currentState === nextState) { return nextState === 'ERROR' ? 'ERROR' : false; } - if (!(currentState in permitedMutations)) { - return nextState; - } - - if (permitedMutations?.[currentState]?.includes(nextState)) { + if (permitedMutations[currentState].includes(nextState)) { return nextState; } return false; }; -const exportSessionKey = async (key: string): Promise => { - key = key.slice(12); - const decodedKey = Base64.decode(key); - - if (!e2e.privateKey) { - throw new Error('Private key not found'); - } +const exportSessionKey = async (key: string): Promise => { + key = key.slice(12); + const decodedKey = Base64.decode(key); - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); - return toString(decryptedKey); + if (!e2e.privateKey) { + throw new Error('Private key not found'); } -const decryptSessionKey = async (key: string): Promise => { - return importAesGcmKey(JSON.parse(await exportSessionKey(key))); - } + const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + return toString(decryptedKey); +}; + +const decryptSessionKey = async (key: string): Promise => { + return importAesGcmKey(JSON.parse(await exportSessionKey(key))); +}; export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; @@ -164,18 +158,6 @@ export class E2ERoom extends Emitter { return this.state === 'READY'; } - isDisabled() { - return this.state === 'DISABLED'; - } - - enable() { - if (this.state === 'READY') { - return; - } - - this.setState('READY'); - } - pause() { this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; @@ -293,7 +275,7 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e + e, ); } } @@ -368,10 +350,6 @@ export class E2ERoom extends Emitter { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } - - - - async handleSubscriptionChanged(sub: ISubscription) { if (sub.E2ESuggestedKey) { await this.handleSuggestedKey(sub.E2ESuggestedKey); diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 698a906e443e4..8a55d0642c0c7 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -15,6 +15,7 @@ - [[FIX] E2E messages not decrypting in message threads](https://github.com/RocketChat/Rocket.Chat/pull/14580) - [[FIX] Not possible to read encrypted messages after disable E2E on channel level](https://github.com/RocketChat/Rocket.Chat/pull/18101) - [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) +- [[FIX] E2E issues](https://github.com/RocketChat/Rocket.Chat/pull/20704) - [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) - [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) - [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) From 63be7e00ef7c6f4c5bbd91261c79eba1a13fee52 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 4 Sep 2025 18:26:51 -0300 Subject: [PATCH 120/251] fix flaky test --- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index d547d02037c14..393d2fd6be61a 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -125,6 +125,7 @@ test.describe('initial setup', () => { const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); await sidenav.logout(); + await expect(loginPage.loginButton).toBeVisible(); await loginPage.loginByUserState(Users.admin); await enterE2EEPasswordBanner.click(); await enterE2EEPasswordModal.forgotPassword(); From 84fbf87590cb16edb9cfec3f5eba5d97e504a74d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 09:04:34 -0300 Subject: [PATCH 121/251] remove adapters from notes --- packages/e2ee/docs/NOTES.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 8a55d0642c0c7..2fe1ac8185c1d 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -41,11 +41,7 @@ ## Tasks ### Shared Package -- Separate all client-side e2ee logic from core into zero-dependency runtime-agnostic [package](../src/index.ts). - - Things like nodejs Buffer and SubtleCrypto are not available from React Native. -- Create adapter for [web](../../e2ee-web/) -- Create adapter for [node](../../e2ee-node/) (testing purposes) -- Create adapter for react-native +- Separate all client-side e2ee logic from core into zero-dependency [package](../src/index.ts). ### Refactor Web From 6ec2a8008cb73bd34894c82321cbfb14502b764c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 09:28:33 -0300 Subject: [PATCH 122/251] simplify persist keys --- packages/e2ee/src/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index f52a018fd7310..06ae5ef6542fc 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -91,12 +91,16 @@ export default class E2EE { } } - async backupKeys(): Promise { - await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword(5), false); + async backupKeys(): Promise { + const res = await this.persistKeys(await this.createRandomPassword(5), false); + if (!res.isOk) { + throw res.error; + } + return res.value; } async changePassphrase(newPassword: string): Promise { - const result = await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, true); + const result = await this.persistKeys(newPassword, true); if (!result.isOk) { throw result.error; @@ -107,7 +111,9 @@ export default class E2EE { } } - private async persistKeys(localKeyPair: LocalKeyPair, password: string, force: boolean): AsyncResult { + private async persistKeys(password: string, force: boolean): AsyncResult { + const localKeyPair = this.getKeysFromLocalStorage(); + if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { return err(new TypeError('Failed to persist keys as they are not strings.')); } From ad48037e305abfe2d7fd6a251c3110b79c46dca3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 09:37:46 -0300 Subject: [PATCH 123/251] remove playwright workflow --- .github/workflows/playwright.yml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/workflows/playwright.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index a94b6417ac4a6..0000000000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g yarn && yarn - - name: Install Playwright Browsers - run: yarn playwright install --with-deps - - name: Run Playwright tests - run: yarn playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 From 9fadf2261bd4f3d199bf362a55e79029cd743db7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 12:34:37 -0300 Subject: [PATCH 124/251] use rest api for requesting subscription keys --- apps/meteor/app/api/server/v1/e2e.ts | 39 +++++++- .../server/methods/requestSubscriptionKeys.ts | 4 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 8 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 51 +++++++---- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 14 ++- playwright.config.ts | 90 ------------------- 6 files changed, 87 insertions(+), 119 deletions(-) delete mode 100644 playwright.config.ts diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 3686f2a7b9007..99b02020d6722 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,4 +1,5 @@ -import { Subscriptions, Users } from '@rocket.chat/models'; +import { api } from '@rocket.chat/core-services'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { ajv, validateUnauthorizedErrorResponse, @@ -75,6 +76,42 @@ const e2eEndpoints = API.v1.post( return API.v1.success(); }, +).get('e2e.requestSubscriptionKeys', { + authRequired: true, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, +}, + async function action() { + // Get all encrypted rooms that the user is subscribed to and has no E2E key yet + const subscriptions = await Subscriptions.findByUserIdWithoutE2E(this.userId).toArray(); + const roomIds = subscriptions.map((subscription) => subscription.rid); + + // For all subscriptions without E2E key, get the rooms that have encryption enabled + const query = { + e2eKeyId: { + $exists: true, + }, + _id: { + $in: roomIds, + }, + }; + + const rooms = Rooms.find(query); + for await (const room of rooms) { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + } + + return API.v1.success(); + } ); API.v1.addRoute( diff --git a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts index cf899a5d64ad7..d3737567be9f3 100644 --- a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts +++ b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts @@ -34,9 +34,9 @@ Meteor.methods({ }; const rooms = Rooms.find(query); - await rooms.forEach((room) => { + for await (const room of rooms) { void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - }); + } return true; }, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index b5984c05e165e..cefcf0db6ec7b 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -243,7 +243,8 @@ export class E2ERoom extends Emitter { }); } catch (e) { this.error( - `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping\n`, + JSON.stringify(e) ); keys.push({ ...key, E2EKey: null }); } @@ -656,10 +657,7 @@ export class E2ERoom extends Emitter { } // Helper function for encryption of messages - encrypt(message: IMessage) { - if (!this.isSupportedRoomType(this.typeOfRoom)) { - return; - } + encrypt(message: { _id: IMessage['_id']; msg: IMessage['msg'] }): Promise { if (!this.groupSessionKey) { throw new Error(t('E2E_Invalid_Key')); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index c154519b0776a..10dd3e62ec457 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -7,7 +7,7 @@ import type { EncryptedKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; import E2EE from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; -import _ from 'lodash'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -49,6 +49,25 @@ const waitForRoom = (rid: IRoom['_id']): Promise => }); }); +const isRoomWithSuggestedKey = (sub: T): sub is T & { E2ESuggestedKey: string, E2EKey?: undefined } => + typeof sub.E2ESuggestedKey === 'string' && !sub.E2EKey; + +const isRoomEncrypted = (sub: T): sub is T & { encrypted: true } => sub.encrypted === true; + +const filterSubscriptions = (filter: (sub: SubscriptionWithRoom) => sub is T) => { + return Subscriptions.state.filter(filter); +}; + +const sampleSize = (array: readonly T[], size: number): T[] => { + if (size >= array.length) return [...array]; + const arr = array.slice(); // don't mutate caller + for (let i = 0; i < size; i++) { + const r = i + Math.floor(Math.random() * (arr.length - i)); // random index in [i, end) + [arr[i], arr[r]] = [arr[r], arr[i]]; + } + return arr.slice(0, size); +} + class E2E extends Emitter<{ READY: void; E2E_STATE_CHANGED: { prevState: E2EEState; nextState: E2EEState }; @@ -121,10 +140,6 @@ class E2E extends Emitter<{ return this.state; } - isEnabled(): boolean { - return this.state !== 'DISABLED'; - } - isReady(): boolean { // Save_Password state is also a ready state for E2EE return this.state === 'READY' || this.state === 'SAVE_PASSWORD'; @@ -203,16 +218,17 @@ class E2E extends Emitter<{ * Handles the suggested E2E key for a subscription. */ async handleAsyncE2EESuggestedKey() { - const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); + const subs = filterSubscriptions(isRoomWithSuggestedKey); + if (!subs.length) { + return; + } await Promise.all( - subs - .flatMap(({ E2ESuggestedKey, E2EKey, rid, encrypted }) => (E2ESuggestedKey && !E2EKey ? [{ rid, E2ESuggestedKey, encrypted }] : [])) - .map(async (sub) => { - await this.room(sub.rid, async (e2eRoom) => { - await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - }); - }), + subs.map(async (sub) => { + await this.room(sub.rid, async (e2eRoom) => { + await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + }); + }), ); } @@ -404,7 +420,7 @@ class E2E extends Emitter<{ } async requestSubscriptionKeys(): Promise { - await sdk.call('e2e.requestSubscriptionKeys'); + await sdk.rest.get('/v1/e2e.requestSubscriptionKeys'); } async encodePrivateKey(privateKey: string, password: string): Promise { @@ -575,8 +591,7 @@ class E2E extends Emitter<{ } async decryptSubscriptions(): Promise { - Subscriptions.state - .filter((subscription) => Boolean(subscription.encrypted)) + filterSubscriptions(isRoomEncrypted) .forEach((subscription) => this.decryptSubscription(subscription._id)); } @@ -669,7 +684,7 @@ class E2E extends Emitter<{ return []; } - const randomRoomIds = _.sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); + const randomRoomIds = sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); const sampleIds: string[] = []; for await (const roomId of randomRoomIds) { diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 393d2fd6be61a..986e25e902300 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -929,14 +929,22 @@ test.describe('e2e-encryption', () => { const rid = await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room'); + if (!rid) { + throw new Error('Room ID not found'); + } + // send old format encrypted message via API const msg = await page.evaluate(async (rid) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports + const { e2e }: typeof import('../../client/lib/e2ee/rocketchat.e2e.ts') = require('/client/lib/e2ee/rocketchat.e2e.ts'); const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg: 'Old format message' }); + return e2eRoom?.encrypt({ _id: 'id', msg: 'Old format message' }); }, rid); + if (!msg) { + throw new Error('Error encrypting message'); + } + await request.post(`${BASE_API_URL}/chat.sendMessage`, { headers: { 'X-Auth-Token': Users.userE2EE.data.loginToken, diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index daaff202c24f3..0000000000000 --- a/playwright.config.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -const baseURL = 'http://localhost:3000'; - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: baseURL, - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'cd apps/meteor && meteor run', - // url: rootUrl.toString(), - // reuseExistingServer: !process.env.CI, - // name: 'Meteor', - // env: { - // ...process.env, - // TEST_MODE: 'true', - // DO_NOT_TRACK: '1', - // }, - // stdout: 'pipe', - // stderr: 'pipe', - // timeout: 2 * 60 * 1000, - // }, -}); From c475e9f7fbd8ffe168f3a86527441d000fc87348 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 12:35:29 -0300 Subject: [PATCH 125/251] stopClient doesn't need to be async --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 10dd3e62ec457..001f220f64932 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -360,7 +360,7 @@ class E2E extends Emitter<{ } } - async stopClient(): Promise { + stopClient(): void { logger.log('-> Stop Client'); this.closeAlert(); @@ -753,5 +753,5 @@ class E2E extends Emitter<{ export const e2e = new E2E(); Accounts.onLogout(() => { - void e2e.stopClient(); + e2e.stopClient(); }); From a29343b963775bdfabfceac691e1b6dbbc4ffcc1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 13:38:18 -0300 Subject: [PATCH 126/251] aes-cbc backwards compat (wip) --- apps/meteor/client/lib/e2ee/helper.ts | 4 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 75 ++++++++++++------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 5 +- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 1 + apps/meteor/tests/e2e/page-objects/login.ts | 1 + 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index b70d547c0ea65..8a9eb7f7ec64f 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -25,7 +25,7 @@ export function toArrayBuffer(thing: any) { return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); } -export function joinVectorAndEcryptedData(vector: any, encryptedData: any) { +export function joinVectorAndEncryptedData(vector: any, encryptedData: any) { const cipherText = new Uint8Array(encryptedData); const output = new Uint8Array(vector.length + cipherText.length); output.set(vector, 0); @@ -33,7 +33,7 @@ export function joinVectorAndEcryptedData(vector: any, encryptedData: any) { return output; } -export function splitVectorAndEcryptedData(cipherText: any) { +export function splitVectorAndEncryptedData(cipherText: Uint8Array): [Uint8Array, Uint8Array] { const vector = cipherText.slice(0, 16); const encryptedData = cipherText.slice(16); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index cefcf0db6ec7b..c1466e8612340 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -8,8 +8,8 @@ import type { E2ERoomState } from './E2ERoomState'; import { toString, toArrayBuffer, - joinVectorAndEcryptedData, - splitVectorAndEcryptedData, + joinVectorAndEncryptedData, + splitVectorAndEncryptedData, encryptRSA, encryptAesGcm, decryptRSA, @@ -23,6 +23,8 @@ import { generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, + encryptAesCbc, + decryptAesCbc, } from './helper'; import { logger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -80,6 +82,12 @@ const decryptSessionKey = async (key: string): Promise => { export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; +export type EncryptionAlgorithm = 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2'; +export type EncryptedContent = { + algorithm: EncryptionAlgorithm; + ciphertext: string; +}; + export class E2ERoom extends Emitter { state: E2ERoomState = 'NOT_STARTED'; @@ -613,15 +621,21 @@ export class E2ERoom extends Emitter { } // Encrypts messages - async encryptText(data: Uint8Array) { + async encryptText(data: Uint8Array, algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2' = 'rc.v2.aes-gcm-sha2'): Promise { const vector = crypto.getRandomValues(new Uint8Array(16)); try { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await encryptAesGcm(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result)); + const result = algorithm === 'rc.v1.aes-sha2' + ? await encryptAesCbc(vector, this.groupSessionKey, data) + : await encryptAesGcm(vector, this.groupSessionKey, data); + const ciphertext = this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); + return { + algorithm, + ciphertext, + } } catch (error) { this.error('Error encrypting message: ', error); throw error; @@ -634,10 +648,8 @@ export class E2ERoom extends Emitter { ) { const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted)); - return { - algorithm: 'rc.v1.aes-sha2', - ciphertext: await this.encryptText(data), - }; + const encrypted = await this.encryptText(data, 'rc.v2.aes-gcm-sha2'); + return encrypted; } // Helper function for encryption of content @@ -674,15 +686,24 @@ export class E2ERoom extends Emitter { }), ); - return this.encryptText(data); + return this.encryptText(data, 'rc.v2.aes-gcm-sha2').then((res) => res.ciphertext); } async decryptContent(data: T) { - if (data.content && data.content.algorithm === 'rc.v1.aes-sha2') { - const content = await this.decrypt(data.content.ciphertext); - Object.assign(data, content); + if (!data.content) { + return data; } + const { content: { algorithm, ciphertext } } = data; + + if (algorithm !== 'rc.v2.aes-gcm-sha2' && algorithm !== 'rc.v1.aes-sha2') { + this.error('Unknown encryption algorithm: ', algorithm); + return data; + } + + const decryptedContent = await this.decrypt({ algorithm, ciphertext }); + Object.assign(data, decryptedContent); + return data; } @@ -693,7 +714,7 @@ export class E2ERoom extends Emitter { } if (message.msg) { - const data = await this.decrypt(message.msg); + const data = await this.decrypt({ algorithm: 'rc.v2.aes-gcm-sha2', ciphertext: message.msg }); if (data?.text) { message.msg = data.text; @@ -708,16 +729,20 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { - const result = await decryptAesGcm(vector, key, cipherText); + async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array, algorithm: EncryptionAlgorithm) { + if (algorithm === 'rc.v2.aes-gcm-sha2') { + const result = await decryptAesGcm(vector, key, cipherText); + return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); + } + const result = await decryptAesCbc(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } - async decrypt(message: string) { - const keyID = message.slice(0, 12); - message = message.slice(12); + async decrypt(content: EncryptedContent) { + const keyID = content.ciphertext.slice(0, 12); + content.ciphertext = content.ciphertext.slice(12); - const [vector, cipherText] = splitVectorAndEcryptedData(Base64.decode(message)); + const [vector, ciphertext] = splitVectorAndEncryptedData(Base64.decode(content.ciphertext)); let oldKey = null; if (keyID !== this.keyID) { @@ -732,9 +757,9 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); + return await this.doDecrypt(vector, this.groupSessionKey, ciphertext, content.algorithm); } catch (error) { - this.error('Error decrypting message: ', error, message); + this.error('Error decrypting message: ', error, content); return { msg: t('E2E_indecipherable') }; } } @@ -743,14 +768,14 @@ export class E2ERoom extends Emitter { try { if (oldKey) { - return await this.doDecrypt(vector, oldKey, cipherText); + return await this.doDecrypt(vector, oldKey, ciphertext, content.algorithm); } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); + return await this.doDecrypt(vector, this.groupSessionKey, ciphertext, content.algorithm); } catch (error) { - this.error('Error decrypting message: ', error, message); + this.error('Error decrypting message: ', error, content); return { msg: t('E2E_Key_Error') }; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 001f220f64932..5e26beefb58f4 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -565,7 +565,10 @@ class E2E extends Emitter<{ return message; } - const data = await e2eRoom.decrypt(pinnedMessage); + const data = await e2eRoom.decrypt({ + algorithm: 'rc.v2.aes-gcm-sha2', + ciphertext: pinnedMessage + }); if (!data) { return message; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 986e25e902300..747c3b3eb7aa3 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -602,6 +602,7 @@ test.describe('e2e-encryption', () => { await poHomeChannel.sidenav.btnCreate.click(); await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); + await expect(page.getByRole('textbox', { name: 'Message @user2' })).toBeVisible() await poHomeChannel.tabs.kebab.click({ force: true }); if (await poHomeChannel.tabs.btnDisableE2E.isVisible()) { diff --git a/apps/meteor/tests/e2e/page-objects/login.ts b/apps/meteor/tests/e2e/page-objects/login.ts index 87caa9312bf58..4e84590cc0db8 100644 --- a/apps/meteor/tests/e2e/page-objects/login.ts +++ b/apps/meteor/tests/e2e/page-objects/login.ts @@ -38,6 +38,7 @@ export class LoginPage { // Injects the login token to the local storage await this.page.evaluate((items) => { + window.localStorage.clear(); items.forEach(({ name, value }) => { window.localStorage.setItem(name, value); }); From b9a612ae366704c8e67b98a1121fe88f4854dc34 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 14:32:17 -0300 Subject: [PATCH 127/251] reset own keys api --- apps/meteor/app/api/server/v1/e2e.ts | 141 +++++++++++------- .../hooks/useResetE2EPasswordMutation.ts | 4 +- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 1 + 3 files changed, 89 insertions(+), 57 deletions(-) diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 99b02020d6722..a80b0ef2aafe5 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -13,6 +13,7 @@ import { } from '@rocket.chat/rest-typings'; import ExpiryMap from 'expiry-map'; +import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSuggestedGroupKey'; @@ -51,68 +52,98 @@ const E2eSetRoomKeyIdSchema = { const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); -const e2eEndpoints = API.v1.post( - 'e2e.setRoomKeyID', - { - authRequired: true, - body: isE2eSetRoomKeyIdProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), +const e2eEndpoints = API.v1 + .post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, }, - }, - async function action() { - const { rid, keyID } = this.bodyParams; + async function action() { + const { rid, keyID } = this.bodyParams; - await setRoomKeyIDMethod(this.userId, rid, keyID); + await setRoomKeyIDMethod(this.userId, rid, keyID); - return API.v1.success(); - }, -).get('e2e.requestSubscriptionKeys', { - authRequired: true, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), - }, -}, - async function action() { - // Get all encrypted rooms that the user is subscribed to and has no E2E key yet - const subscriptions = await Subscriptions.findByUserIdWithoutE2E(this.userId).toArray(); - const roomIds = subscriptions.map((subscription) => subscription.rid); - - // For all subscriptions without E2E key, get the rooms that have encryption enabled - const query = { - e2eKeyId: { - $exists: true, - }, - _id: { - $in: roomIds, + return API.v1.success(); + }, + ) + .get( + 'e2e.requestSubscriptionKeys', + { + authRequired: true, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), }, - }; + }, + async function action() { + // Get all encrypted rooms that the user is subscribed to and has no E2E key yet + const subscriptions = await Subscriptions.findByUserIdWithoutE2E(this.userId).toArray(); + const roomIds = subscriptions.map((subscription) => subscription.rid); + + // For all subscriptions without E2E key, get the rooms that have encryption enabled + const query = { + e2eKeyId: { + $exists: true, + }, + _id: { + $in: roomIds, + }, + }; - const rooms = Rooms.find(query); - for await (const room of rooms) { - void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - } + const rooms = Rooms.find(query); + for await (const room of rooms) { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + } - return API.v1.success(); - } -); + return API.v1.success(); + }, + ) + .post( + 'e2e.resetOwnE2EKey', + { + twoFactorRequired: true, + authRequired: true, + body: ajv.compile({ type: 'object', properties: {}, additionalProperties: false }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + }), + }, + }, + async function action() { + if (!await resetUserE2EEncriptionKey(this.userId, false)) { + return API.v1.failure('failed-reset-e2e-password'); + } + + return API.v1.success(); + }, + ); API.v1.addRoute( 'e2e.fetchMyKeys', diff --git a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts index 652c4e8b02143..83d59732dd670 100644 --- a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts +++ b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts @@ -1,4 +1,4 @@ -import { useLogout, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useLogout, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { MutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -7,7 +7,7 @@ export const useResetE2EPasswordMutation = ({ options }: { options?: MutationOpt const { t } = useTranslation(); const logout = useLogout(); - const resetE2eKey = useMethod('e2e.resetOwnE2EKey'); + const resetE2eKey = useEndpoint('POST', '/v1/e2e.resetOwnE2EKey'); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 747c3b3eb7aa3..898eee197e0da 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -495,6 +495,7 @@ test.describe('e2e-encryption', () => { await poHomeChannel.dismissToast(); + await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await poHomeChannel.tabs.kebab.click(); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click(); From 203998248327d9f983bd215b3c8223a8c9786f5f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 5 Sep 2025 14:54:13 -0300 Subject: [PATCH 128/251] state READY -> KEYS_RECEIVED --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index c1466e8612340..7a70487ecc7f5 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -45,7 +45,7 @@ const permitedMutations: Mutations = { ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], KEYS_RECEIVED: ['ESTABLISHING'], CREATING_KEYS: ['READY', 'KEYS_RECEIVED'], - READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], + READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS', 'KEYS_RECEIVED'], ERROR: ['KEYS_RECEIVED', 'NOT_STARTED', 'ERROR'], WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], DISABLED: [], From 4e118d57f20c2e65693d07b343c617861a072633 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 13:16:43 -0300 Subject: [PATCH 129/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 2fe1ac8185c1d..4cdf9177d11e7 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -20,6 +20,7 @@ - [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) - [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) +- [[IMPROVE] Quotes on E2EE Messages](https://github.com/RocketChat/Rocket.Chat/pull/26303) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) - [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) From 8efc67d1db9bbe28e36a72f33279c516f3b541f3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 16:55:01 -0300 Subject: [PATCH 130/251] fix initial handshake --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 7a70487ecc7f5..bee6000e13dd7 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -38,25 +38,50 @@ import { roomCoordinator } from '../rooms/roomCoordinator'; const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { [K in E2ERoomState]: E2ERoomState[] }; +type Mutations = { [K in E2ERoomState]: { + [K2 in E2ERoomState]?: true; +} }; const permitedMutations: Mutations = { - NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'], - ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], - KEYS_RECEIVED: ['ESTABLISHING'], - CREATING_KEYS: ['READY', 'KEYS_RECEIVED'], - READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS', 'KEYS_RECEIVED'], - ERROR: ['KEYS_RECEIVED', 'NOT_STARTED', 'ERROR'], - WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], - DISABLED: [], + NOT_STARTED: { + ESTABLISHING: true, + DISABLED: true, + KEYS_RECEIVED: true, + }, + ESTABLISHING: { + READY: true, + KEYS_RECEIVED: true, + ERROR: true, + DISABLED: true, + WAITING_KEYS: true, + CREATING_KEYS: true, + }, + KEYS_RECEIVED: { + ESTABLISHING: true, + }, + CREATING_KEYS: { + READY: true, + KEYS_RECEIVED: true, + }, + READY: { + DISABLED: true, + CREATING_KEYS: true, + WAITING_KEYS: true, + }, + ERROR: { + KEYS_RECEIVED: true, + NOT_STARTED: true, + }, + WAITING_KEYS: { + KEYS_RECEIVED: true, + ERROR: true, + DISABLED: true, + }, + DISABLED: {}, }; const filterMutation = (currentState: E2ERoomState, nextState: E2ERoomState): E2ERoomState | false => { - if (currentState === nextState) { - return nextState === 'ERROR' ? 'ERROR' : false; - } - - if (permitedMutations[currentState].includes(nextState)) { + if (permitedMutations[currentState][nextState]) { return nextState; } @@ -121,18 +146,15 @@ export class E2ERoom extends Emitter { return this.decryptPendingMessages(); }); this.once('READY', () => this.decryptSubscription()); - this.on('STATE_CHANGED', (prev) => { - if (this.roomId === RoomManager.opened) { - this.log(`[PREV: ${prev}]`, 'State CHANGED'); - } - }); this.on('STATE_CHANGED', () => this.handshake()); - this.handshake(); } log(...msg: unknown[]): void { - logger.log(`[${this.roomId} (${this.state})]`, ...msg); + if (this.roomId !== RoomManager.opened) { + return; + } + logger.info(`[${this.roomId} (${this.state})]`, ...msg); } error(...msg: unknown[]): void { @@ -385,11 +407,11 @@ export class E2ERoom extends Emitter { async handleSuggestedKey(suggestedKey: string) { if (await this.importGroupKey(suggestedKey)) { - logger.log('Imported valid E2E suggested key'); + this.log('Imported valid E2E suggested key'); await this.acceptSuggestedKey(); this.setState('KEYS_RECEIVED'); } else { - logger.error('Invalid E2ESuggestedKey, rejecting', suggestedKey); + this.error('Invalid E2ESuggestedKey, rejecting', suggestedKey); await this.rejectSuggestedKey(); } } From 33b844510da70caac6ec66842ca58f4248944e91 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 17:03:13 -0300 Subject: [PATCH 131/251] add more history --- packages/e2ee/docs/NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index 4cdf9177d11e7..b4e688c5060f8 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -35,6 +35,7 @@ - [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) - [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) +- [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) - [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) - [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) - [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) From bad7af8326023cc9a38f56f6c918803d4c56e715 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 17:03:37 -0300 Subject: [PATCH 132/251] reduce logs --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index bee6000e13dd7..ff05d128655fd 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -154,11 +154,11 @@ export class E2ERoom extends Emitter { if (this.roomId !== RoomManager.opened) { return; } - logger.info(`[${this.roomId} (${this.state})]`, ...msg); + logger.info(`[${this.roomId}]`, ...msg); } error(...msg: unknown[]): void { - logger.error(`[${this.roomId} (${this.state})]`, ...msg); + logger.error(`[${this.roomId}]`, ...msg); } hasSessionKey(): boolean { From 1b9539c9adc2ec3d5f6068c976ed1b502ea1039b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 17:17:14 -0300 Subject: [PATCH 133/251] comment out duplicate room waiting --- .../root/hooks/loggedIn/useE2EEncryption.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 9eee3e1f93d05..6ecef2b960689 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -1,4 +1,4 @@ -import { isE2EEPinnedMessage, type IRoom, type IMessage } from '@rocket.chat/core-typings'; +import { isE2EEPinnedMessage, type IMessage } from '@rocket.chat/core-typings'; import { useUserId, useSetting, useRouter, useLayout, useUser } from '@rocket.chat/ui-contexts'; import { useEffect, useRef } from 'react'; @@ -7,7 +7,6 @@ import { e2e } from '../../../../lib/e2ee'; import { logger } from '../../../../lib/e2ee/logger'; import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived'; -import { Rooms } from '../../../../stores'; import { useE2EEState } from '../../../room/hooks/useE2EEState'; export const useE2EEncryption = () => { @@ -80,21 +79,21 @@ export const useE2EEncryption = () => { // e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be // refactored to avoid waiting for the room again - const subscription = await new Promise((resolve) => { - const room = Rooms.state.get(message.rid); + // const subscription = await new Promise((resolve) => { + // const room = Rooms.state.get(message.rid); - if (room) resolve(room); + // if (room) resolve(room); - const unsubscribe = Rooms.use.subscribe((state) => { - const room = state.get(message.rid); - if (room) { - unsubscribe(); - resolve(room); - } - }); - }); + // const unsubscribe = Rooms.use.subscribe((state) => { + // const room = state.get(message.rid); + // if (room) { + // unsubscribe(); + // resolve(room); + // } + // }); + // }); - subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + // subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause(); const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message); From 1818341f2a8fa8d1aab58e48b18abf96602a0041 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 18:13:46 -0300 Subject: [PATCH 134/251] make tests run independent of cwd --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 33 +++++++++++-------- .../page-objects/fragments/home-content.ts | 11 ++++--- apps/meteor/tests/e2e/utils/getFilePath.ts | 7 ++++ 3 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 apps/meteor/tests/e2e/utils/getFilePath.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index ff05d128655fd..2db01e826911b 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -38,9 +38,11 @@ import { roomCoordinator } from '../rooms/roomCoordinator'; const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { [K in E2ERoomState]: { - [K2 in E2ERoomState]?: true; -} }; +type Mutations = { + [K in E2ERoomState]: { + [K2 in E2ERoomState]?: true; + }; +}; const permitedMutations: Mutations = { NOT_STARTED: { @@ -274,7 +276,7 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping\n`, - JSON.stringify(e) + JSON.stringify(e), ); keys.push({ ...key, E2EKey: null }); } @@ -643,21 +645,25 @@ export class E2ERoom extends Emitter { } // Encrypts messages - async encryptText(data: Uint8Array, algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2' = 'rc.v2.aes-gcm-sha2'): Promise { + async encryptText( + data: Uint8Array, + algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2' = 'rc.v2.aes-gcm-sha2', + ): Promise { const vector = crypto.getRandomValues(new Uint8Array(16)); try { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = algorithm === 'rc.v1.aes-sha2' - ? await encryptAesCbc(vector, this.groupSessionKey, data) - : await encryptAesGcm(vector, this.groupSessionKey, data); + const result = + algorithm === 'rc.v1.aes-sha2' + ? await encryptAesCbc(vector, this.groupSessionKey, data) + : await encryptAesGcm(vector, this.groupSessionKey, data); const ciphertext = this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); return { algorithm, ciphertext, - } + }; } catch (error) { this.error('Error encrypting message: ', error); throw error; @@ -692,7 +698,6 @@ export class E2ERoom extends Emitter { // Helper function for encryption of messages encrypt(message: { _id: IMessage['_id']; msg: IMessage['msg'] }): Promise { - if (!this.groupSessionKey) { throw new Error(t('E2E_Invalid_Key')); } @@ -716,15 +721,17 @@ export class E2ERoom extends Emitter { return data; } - const { content: { algorithm, ciphertext } } = data; + const { + content: { algorithm, ciphertext }, + } = data; if (algorithm !== 'rc.v2.aes-gcm-sha2' && algorithm !== 'rc.v1.aes-sha2') { this.error('Unknown encryption algorithm: ', algorithm); return data; } - const decryptedContent = await this.decrypt({ algorithm, ciphertext }); - Object.assign(data, decryptedContent); + const decryptedContent = await this.decrypt({ algorithm, ciphertext }); + Object.assign(data, decryptedContent); return data; } diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 83e2387f9dbfd..410a4d76a3014 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -1,7 +1,8 @@ -import fs from 'fs/promises'; +import { readFile } from 'node:fs/promises'; import type { Locator, Page } from '@playwright/test'; +import { getFilePath } from '../../utils/getFilePath'; import { expect } from '../../utils/test'; export class HomeContent { @@ -379,7 +380,7 @@ export class HomeContent { } async dragAndDropTxtFile(): Promise { - const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await readFile(getFilePath('any_file.txt'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -395,7 +396,7 @@ export class HomeContent { } async dragAndDropLstFile(): Promise { - const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); + const contract = await readFile(getFilePath('lst-test.lst'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'lst-test.lst', { @@ -411,7 +412,7 @@ export class HomeContent { } async dragAndDropTxtFileToThread(): Promise { - const contract = await fs.readFile('./apps/meteor/tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await readFile(getFilePath('any_file.txt'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -427,7 +428,7 @@ export class HomeContent { } async sendFileMessage(fileName: string): Promise { - await this.page.locator('input[type=file]').setInputFiles(`./apps/meteor/tests/e2e/fixtures/files/${fileName}`); + await this.page.locator('input[type=file]').setInputFiles(getFilePath(fileName)); } async openLastMessageMenu(): Promise { diff --git a/apps/meteor/tests/e2e/utils/getFilePath.ts b/apps/meteor/tests/e2e/utils/getFilePath.ts new file mode 100644 index 0000000000000..2d109c1ff5665 --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getFilePath.ts @@ -0,0 +1,7 @@ +import { resolve, join, relative } from 'node:path'; + +const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../fixtures/files')); + +export function getFilePath(fileName: string): string { + return join(FIXTURES_PATH, fileName); +} From af1ba4186edb62522abd2c690887c0b45aafc672 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 19:24:59 -0300 Subject: [PATCH 135/251] remove unneeded shouldConvertReceivedMessages method --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 4 ---- .../room/ImageGallery/hooks/useImagesList.ts | 2 +- .../RoomFiles/hooks/useFilesList.ts | 2 +- .../root/hooks/loggedIn/useE2EEncryption.ts | 20 +------------------ 4 files changed, 3 insertions(+), 25 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 2db01e826911b..6d4c4754c69d8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -220,10 +220,6 @@ export class E2ERoom extends Emitter { return true; } - shouldConvertReceivedMessages() { - return this.isReady(); - } - isWaitingKeys() { return this.state === 'WAITING_KEYS'; } diff --git a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts index b753a5a7cc33a..217d561545d84 100644 --- a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts +++ b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts @@ -51,7 +51,7 @@ export const useImagesList = ( for await (const file of items) { if (file.rid && file.content) { const e2eRoom = await e2e.getInstanceByRoomId(file.rid); - if (e2eRoom?.shouldConvertReceivedMessages()) { + if (e2eRoom?.isReady()) { const decrypted = await e2e.decryptFileContent(file); const key = Base64.encode( JSON.stringify({ diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts index 35555709c6362..0bbf7159e68da 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts @@ -65,7 +65,7 @@ export const useFilesList = ( for await (const file of items) { if (file.rid && file.content) { const e2eRoom = await e2e.getInstanceByRoomId(file.rid); - if (e2eRoom?.shouldConvertReceivedMessages()) { + if (e2eRoom?.isReady()) { const decrypted = await e2e.decryptFileContent(file); const key = Base64.encode( JSON.stringify({ diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 6ecef2b960689..0f79d099ad7db 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -58,7 +58,7 @@ export const useE2EEncryption = () => { const offClientMessageReceived = onClientMessageReceived.use(async (msg) => { const e2eRoom = await e2e.getInstanceByRoomId(msg.rid); - if (!e2eRoom?.shouldConvertReceivedMessages()) { + if (!e2eRoom?.isReady()) { return msg; } @@ -77,24 +77,6 @@ export const useE2EEncryption = () => { return message; } - // e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be - // refactored to avoid waiting for the room again - // const subscription = await new Promise((resolve) => { - // const room = Rooms.state.get(message.rid); - - // if (room) resolve(room); - - // const unsubscribe = Rooms.use.subscribe((state) => { - // const room = state.get(message.rid); - // if (room) { - // unsubscribe(); - // resolve(room); - // } - // }); - // }); - - // subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message); if (!shouldConvertSentMessages) return message; From 9d8cab64b195dd45612e809424ede0db32d5953a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 6 Sep 2025 20:15:19 -0300 Subject: [PATCH 136/251] update state diagrams --- packages/e2ee/docs/state_v1.mmd | 99 +++++++++++++++++++++------------ packages/e2ee/docs/state_v2.mmd | 72 ------------------------ packages/e2ee/docs/state_v3.mmd | 58 ------------------- packages/e2ee/docs/state_v4.mmd | 61 -------------------- 4 files changed, 64 insertions(+), 226 deletions(-) delete mode 100644 packages/e2ee/docs/state_v2.mmd delete mode 100644 packages/e2ee/docs/state_v3.mmd delete mode 100644 packages/e2ee/docs/state_v4.mmd diff --git a/packages/e2ee/docs/state_v1.mmd b/packages/e2ee/docs/state_v1.mmd index 413355c96b46a..beb7be8c6cb01 100644 --- a/packages/e2ee/docs/state_v1.mmd +++ b/packages/e2ee/docs/state_v1.mmd @@ -1,42 +1,71 @@ -stateDiagram-v2 - direction TB - %% External events / triggers noted after colon - [*] --> NOT_STARTED: init E2E instance - NOT_STARTED --> LOADING_KEYS: startClient() - NOT_STARTED --> DISABLED: stopClient() +sequenceDiagram + autonumber + participant U as User Client + participant RS as Room State + participant Sub as Subscriptions Store + participant API as REST API + participant KMS as Key Source (peer/creator) - LOADING_KEYS --> ENTER_PASSWORD: need user password - LOADING_KEYS --> READY: keys loaded - LOADING_KEYS --> READY: new keys created - LOADING_KEYS --> ERROR: fetch/import failure + Note over RS: Initial load + U->>RS: Open room + RS->>RS: state = NOT_STARTED - ENTER_PASSWORD --> LOADING_KEYS: password accepted - ENTER_PASSWORD --> ENTER_PASSWORD: wrong password - ENTER_PASSWORD --> ERROR: unrecoverable error + alt Existing key present + Sub-->>RS: E2EKey (room subscription) + RS->>RS: state = KEYS_RECEIVED + else Suggested key rotation + Sub-->>RS: E2ESuggestedKey + RS->>RS: importSuggestedKey() + RS-->>API: POST /v1/e2e.acceptSuggestedGroupKey + RS->>RS: state = KEYS_RECEIVED + else No key yet + RS->>RS: remain NOT_STARTED + end - READY --> SAVE_PASSWORD: random password present - SAVE_PASSWORD --> READY: user saved password + U->>RS: handshake() + RS->>RS: Guard: require state in {NOT_STARTED, KEYS_RECEIVED} & e2e.isReady() + RS->>RS: state = ESTABLISHING - READY --> DISABLED: stopClient() - SAVE_PASSWORD --> DISABLED: stopClient() + opt Fast-path import + Sub-->>RS: (lookup) E2EKey + RS->>RS: importGroupKey() + RS->>RS: state = READY + RS-->>U: Handshake complete + end - READY --> ERROR: runtime error - SAVE_PASSWORD --> ERROR: runtime error + alt No existing key + RS-->>API: Request /v1/e2e.requestSubscriptionKeys + API-->>RS: 404 / no material + RS->>RS: state = CREATING_KEYS + RS->>RS: generate group key + RS-->>API: publish new key + RS->>RS: state = READY + RS-->>U: Handshake complete + else Key arrives late + par Parallel events + API-->>RS: E2EKey via subscription update + RS-->>RS: state = KEYS_RECEIVED + and RS->>RS: continuing ESTABLISHING + end + RS->>RS: importGroupKey() (idempotent) + RS->>RS: state = READY + RS-->>U: Handshake complete + end - ERROR --> NOT_STARTED: restart - DISABLED --> NOT_STARTED: startClient() + rect rgba(255,200,0,0.15) + note over RS: ERROR path + RS-->>RS: on fetch/import error -> state = ERROR + Sub-->>RS: Later E2EKey + RS->>RS: state = KEYS_RECEIVED + U->>RS: handshake() retry + end - state NOT_STARTED { - [*] --> idle - idle --> [*] - } - - note right of LOADING_KEYS - Fetch/generate keys - Maybe persist encoded key - end note - - note right of SAVE_PASSWORD - Banner + modal prompts user - to store random password - end note \ No newline at end of file + rect rgba(150,200,255,0.15) + note over RS: Key rotation + Sub-->>RS: E2ESuggestedKey + RS->>RS: importGroupKey() (new keyID) + RS-->>API: acceptSuggestedGroupKey + RS->>RS: state = KEYS_RECEIVED + U->>RS: handshake() (re-establish) + RS->>RS: state = ESTABLISHING -> READY + end \ No newline at end of file diff --git a/packages/e2ee/docs/state_v2.mmd b/packages/e2ee/docs/state_v2.mmd deleted file mode 100644 index db27c554decab..0000000000000 --- a/packages/e2ee/docs/state_v2.mmd +++ /dev/null @@ -1,72 +0,0 @@ -stateDiagram-v2 - direction TB - - %% State styling definitions - classDef normal fill:#eef,stroke:#336,stroke-width:1px - classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px - classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px - classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px - - %% Flow start - [*] --> NOT_STARTED: init E2E instance - - %% Start / stop - NOT_STARTED --> LOADING_KEYS: startClient() - NOT_STARTED --> DISABLED: stopClient() - - %% Loading phase - LOADING_KEYS --> ENTER_PASSWORD: need user password / show password banner + modal - LOADING_KEYS --> READY: keys loaded from local & server - LOADING_KEYS --> READY: new keypair generated - LOADING_KEYS --> ERROR: fetch/import failure - - %% Password entry loop - ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted - ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (toast errors) - ENTER_PASSWORD --> ERROR: unrecoverable error - - %% Ready & save password banner - READY --> SAVE_PASSWORD: random password present -> show SavePassword banner + modal - SAVE_PASSWORD --> READY: user confirms save (modal confirm) - - %% Disable - READY --> DISABLED: stopClient() - SAVE_PASSWORD --> DISABLED: stopClient() - - %% Runtime errors - READY --> ERROR: runtime error - SAVE_PASSWORD --> ERROR: runtime error - - %% Recovery paths - ERROR --> NOT_STARTED: restart flow (startClient()) - DISABLED --> NOT_STARTED: re-enable (startClient()) - - %% Composite example (minimal placeholder) for NOT_STARTED internal idle - state NOT_STARTED { - [*] --> idle - idle --> [*] - } - - %% Notes for key phases - note right of LOADING_KEYS - Fetch or generate RSA keys - Maybe persist encoded private key to server - Decide if password required - end note - - note right of ENTER_PASSWORD - Banner+Modal: EnterE2EPasswordModal - User attempts decrypt private key - Toast on failure - end note - - note right of SAVE_PASSWORD - Banner+Modal: SaveE2EPasswordModal - Prompt user to store random password - end note - - %% Class assignments - class NOT_STARTED,READY,DISABLED normal - class LOADING_KEYS action - class ENTER_PASSWORD,SAVE_PASSWORD warning - class ERROR error \ No newline at end of file diff --git a/packages/e2ee/docs/state_v3.mmd b/packages/e2ee/docs/state_v3.mmd deleted file mode 100644 index 53bc1064325a2..0000000000000 --- a/packages/e2ee/docs/state_v3.mmd +++ /dev/null @@ -1,58 +0,0 @@ -stateDiagram-v2 - direction TB - - %% Style definitions (note: cannot style internal composite states per Mermaid limitations) - classDef normal fill:#eef,stroke:#336,stroke-width:1px - classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px - classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px - classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px - - [*] --> NOT_STARTED: init E2E instance - NOT_STARTED --> LOADING_KEYS: startClient() - NOT_STARTED --> DISABLED: stopClient() - - LOADING_KEYS --> ENTER_PASSWORD: need user password - LOADING_KEYS --> OPERATIONAL: keys loaded / new keypair - LOADING_KEYS --> ERROR: fetch/import failure - - ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted - ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (retry) - ENTER_PASSWORD --> ERROR: unrecoverable error - - OPERATIONAL --> DISABLED: stopClient() - OPERATIONAL --> ERROR: runtime error - - ERROR --> NOT_STARTED: restart (startClient()) - DISABLED --> NOT_STARTED: re-enable (startClient()) - - %% Composite operational state with concurrency: Ready flow || Key distribution loop - state OPERATIONAL { - direction TB - %% Region 1: User readiness / password banner flow - state ReadyFlow { - [*] --> READY - READY --> SAVE_PASSWORD: random password present - SAVE_PASSWORD --> READY: user saved password - } - -- - %% Region 2: Key distribution periodic task - [*] --> KD_IDLE - KD_IDLE --> KD_SAMPLE: interval tick (10s) - KD_SAMPLE --> KD_FETCH: sample rooms have candidates - KD_SAMPLE --> KD_IDLE: no rooms to process - KD_FETCH --> KD_PROVIDE: usersWaitingForE2EKeys found - KD_FETCH --> KD_IDLE: none waiting - KD_PROVIDE --> KD_IDLE: POST success / handled - } - - note right of OPERATIONAL - Two concurrent regions: - 1) ReadyFlow manages READY <-> SAVE_PASSWORD - 2) KeyDistribution loop runs every 10s - Loop exits implicitly when leaving OPERATIONAL - end note - - class NOT_STARTED,DISABLED normal - class LOADING_KEYS action - class ENTER_PASSWORD warning - class ERROR error \ No newline at end of file diff --git a/packages/e2ee/docs/state_v4.mmd b/packages/e2ee/docs/state_v4.mmd deleted file mode 100644 index a571ddbd8eb13..0000000000000 --- a/packages/e2ee/docs/state_v4.mmd +++ /dev/null @@ -1,61 +0,0 @@ -stateDiagram-v2 - direction TB - - classDef normal fill:#eef,stroke:#336,stroke-width:1px - classDef action fill:#e8ffe8,stroke:#2a6,stroke-width:1px - classDef warning fill:#fff4d6,stroke:#c90,stroke-width:1px - classDef error fill:#ffe6e6,stroke:#c33,stroke-width:1px - - [*] --> NOT_STARTED: init E2E instance - NOT_STARTED --> LOADING_KEYS: startClient() - NOT_STARTED --> DISABLED: stopClient() - - LOADING_KEYS --> ENTER_PASSWORD: need user password - LOADING_KEYS --> OPERATIONAL: keys loaded / new keypair - LOADING_KEYS --> ERROR: fetch/import failure - - ENTER_PASSWORD --> LOADING_KEYS: password accepted / private key decrypted - ENTER_PASSWORD --> ENTER_PASSWORD: wrong password (retry) - ENTER_PASSWORD --> ERROR: unrecoverable error - - OPERATIONAL --> DISABLED: stopClient() - OPERATIONAL --> ERROR: runtime error (global) - - ERROR --> NOT_STARTED: restart (startClient()) - DISABLED --> NOT_STARTED: re-enable (startClient()) - - state OPERATIONAL { - direction TB - state ReadyFlow { - [*] --> READY - READY --> SAVE_PASSWORD: random password present - SAVE_PASSWORD --> READY: user saved password - READY --> ERROR: decrypt / subscription failure - SAVE_PASSWORD --> ERROR: modal / banner failure - } - -- - [*] --> KD_IDLE - KD_IDLE --> KD_SAMPLE: interval tick (10s) - KD_SAMPLE --> KD_FETCH: rooms sampled - KD_SAMPLE --> KD_IDLE: none to process - KD_FETCH --> KD_PROVIDE: candidates found - KD_FETCH --> KD_IDLE: none waiting - KD_PROVIDE --> KD_IDLE: POST success - - %% Error transitions inside key distribution loop - KD_IDLE --> ERROR: interval setup fail - KD_SAMPLE --> ERROR: sampling error - KD_FETCH --> ERROR: fetch usersWaitingForE2EKeys fail - KD_PROVIDE --> ERROR: provide keys POST fail - } - - note right of OPERATIONAL - OPERATIONAL has two concurrent regions. - Each internal state can surface errors directly - to ERROR for unified recovery. - end note - - class NOT_STARTED,DISABLED normal - class LOADING_KEYS action - class ENTER_PASSWORD warning - class ERROR error \ No newline at end of file From 9bf3345c49ceadcc0d90431d412513faac6a4b4a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 8 Sep 2025 19:07:05 -0300 Subject: [PATCH 137/251] remove querystring and url imports --- .../hooks/notification/useDesktopNotification.ts | 5 ++++- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 6 ++++-- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 11 +++++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index 6d7a24317e4d0..0ab742bf203cf 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -25,7 +25,10 @@ export const useDesktopNotification = () => { if (notification.payload.message?.t === 'e2e') { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { - notification.text = (await e2eRoom.decrypt(notification.payload.message.msg)).text; + notification.text = (await e2eRoom.decrypt({ + ciphertext: notification.payload.message.msg, + algorithm: 'rc.v2.aes-gcm-sha2' + })).text; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 6d4c4754c69d8..8705ca09a7655 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -693,7 +693,7 @@ export class E2ERoom extends Emitter { } // Helper function for encryption of messages - encrypt(message: { _id: IMessage['_id']; msg: IMessage['msg'] }): Promise { + async encrypt(message: { _id: IMessage['_id']; msg: IMessage['msg'] }): Promise { if (!this.groupSessionKey) { throw new Error(t('E2E_Invalid_Key')); } @@ -709,7 +709,9 @@ export class E2ERoom extends Emitter { }), ); - return this.encryptText(data, 'rc.v2.aes-gcm-sha2').then((res) => res.ciphertext); + const encrypted = await this.encryptText(data, 'rc.v2.aes-gcm-sha2'); + + return encrypted.ciphertext; } async decryptContent(data: T) { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 5e26beefb58f4..76057d54642db 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,5 +1,4 @@ -import QueryString from 'querystring'; -import URL from 'url'; + import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; @@ -620,15 +619,15 @@ class E2E extends Emitter<{ return; } - const urlObj = URL.parse(url); + const urlObj = new URL(url); // if the URL doesn't have query params (doesn't reference message) skip - if (!urlObj.query) { + if (!urlObj.search) { return; } - const { msg: msgId } = QueryString.parse(urlObj.query); + const [msgId, ...rest] = urlObj.searchParams.getAll('msg'); - if (!msgId || Array.isArray(msgId)) { + if (!msgId || rest.length > 0) { return; } From 24f86f458c98d9c095eeca225bb551fae46102a2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 8 Sep 2025 22:01:29 -0300 Subject: [PATCH 138/251] fix group key id --- apps/meteor/client/lib/e2ee/helper.ts | 10 ++++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 36 +++++++++++++------ packages/e2ee/src/index.ts | 2 +- packages/e2ee/src/vector.ts | 4 +-- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 8a9eb7f7ec64f..db7e5b85bbfdd 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -33,9 +33,9 @@ export function joinVectorAndEncryptedData(vector: any, encryptedData: any) { return output; } -export function splitVectorAndEncryptedData(cipherText: Uint8Array): [Uint8Array, Uint8Array] { - const vector = cipherText.slice(0, 16); - const encryptedData = cipherText.slice(16); +export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16): [Uint8Array, Uint8Array] { + const vector = cipherText.slice(0, ivLength); + const encryptedData = cipherText.slice(ivLength); return [vector, encryptedData]; } @@ -61,6 +61,10 @@ export async function decryptAesCbc(vector: Uint8Array, key: Crypto } export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, data: BufferSource) { + if (iv.length !== 12) { + throw new Error('Invalid iv length for AES-GCM. Must be 12 bytes.'); + } + return crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 8705ca09a7655..aefc752f583d4 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -69,6 +69,7 @@ const permitedMutations: Mutations = { DISABLED: true, CREATING_KEYS: true, WAITING_KEYS: true, + KEYS_RECEIVED: true }, ERROR: { KEYS_RECEIVED: true, @@ -82,7 +83,11 @@ const permitedMutations: Mutations = { DISABLED: {}, }; -const filterMutation = (currentState: E2ERoomState, nextState: E2ERoomState): E2ERoomState | false => { +const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { + if (!currentState) { + return nextState; + } + if (permitedMutations[currentState][nextState]) { return nextState; } @@ -116,7 +121,7 @@ export type EncryptedContent = { }; export class E2ERoom extends Emitter { - state: E2ERoomState = 'NOT_STARTED'; + state: E2ERoomState | undefined = undefined; [PAUSED] = false; @@ -153,13 +158,16 @@ export class E2ERoom extends Emitter { } log(...msg: unknown[]): void { - if (this.roomId !== RoomManager.opened) { + if (!this.isCurrentRoom()) { return; } logger.info(`[${this.roomId}]`, ...msg); } error(...msg: unknown[]): void { + if (!this.isCurrentRoom()) { + return; + } logger.error(`[${this.roomId}]`, ...msg); } @@ -167,7 +175,7 @@ export class E2ERoom extends Emitter { return !!this.groupSessionKey; } - getState(): E2ERoomState { + getState(): E2ERoomState | undefined { return this.state; } @@ -333,7 +341,7 @@ export class E2ERoom extends Emitter { } // Initiates E2E Encryption - async handshake(): Promise { + async handshake() { if (!e2e.isReady()) { return; } @@ -370,8 +378,8 @@ export class E2ERoom extends Emitter { this.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - this.error('Error during handshake: ', error); this.setState('ERROR'); + this.error('Error requesting group key: ', error); } } @@ -645,7 +653,8 @@ export class E2ERoom extends Emitter { data: Uint8Array, algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2' = 'rc.v2.aes-gcm-sha2', ): Promise { - const vector = crypto.getRandomValues(new Uint8Array(16)); + const ivLength = algorithm === 'rc.v1.aes-sha2' ? 16 : 12 as const; + const vector = crypto.getRandomValues(new Uint8Array(ivLength)); try { if (!this.groupSessionKey) { @@ -761,15 +770,17 @@ export class E2ERoom extends Emitter { const result = await decryptAesGcm(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } + const result = await decryptAesCbc(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } async decrypt(content: EncryptedContent) { + const ivLength = content.algorithm === 'rc.v2.aes-gcm-sha2' ? 12 : 16 as const; const keyID = content.ciphertext.slice(0, 12); content.ciphertext = content.ciphertext.slice(12); - const [vector, ciphertext] = splitVectorAndEncryptedData(Base64.decode(content.ciphertext)); + const [vector, ciphertext] = splitVectorAndEncryptedData(Base64.decode(content.ciphertext), ivLength); let oldKey = null; if (keyID !== this.keyID) { @@ -822,10 +833,9 @@ export class E2ERoom extends Emitter { const usersWithKeys = await Promise.all( users.map(async (user) => { const { _id, public_key } = user; - const jwk = JSON.parse(public_key); - const key = await this.encryptGroupKeyForParticipant(jwk); + const key = await this.encryptGroupKeyForParticipant(public_key); if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(jwk, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys); return { _id, key, oldKeys }; } return { _id, key }; @@ -834,4 +844,8 @@ export class E2ERoom extends Emitter { return usersWithKeys; } + + isCurrentRoom() { + return this.roomId === RoomManager.opened; + } } diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index 06ae5ef6542fc..bf68e521fa727 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -139,7 +139,7 @@ export default class E2EE { if (!masterKey.isOk) { return masterKey; } - const IV_LENGTH = 16; + const IV_LENGTH = 12; const vector = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); try { const privateKeyBuffer = toArrayBuffer(privateKey); diff --git a/packages/e2ee/src/vector.ts b/packages/e2ee/src/vector.ts index a527e9cac7bcf..4a648c0bf3738 100644 --- a/packages/e2ee/src/vector.ts +++ b/packages/e2ee/src/vector.ts @@ -1,5 +1,5 @@ -const ZERO = 0x00; -const IV_LENGTH = 0x10; +const ZERO = 0; +const IV_LENGTH = 12; export const joinVectorAndEncryptedData = (vector: Uint8Array, cipherText: ArrayBuffer): Uint8Array => { if (vector.length !== IV_LENGTH) { From a45801d867b1d3e8bdcfba2cc9fb4ba225caf0c8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 9 Sep 2025 13:37:53 -0300 Subject: [PATCH 139/251] revert some changes --- apps/meteor/app/api/server/v1/e2e.ts | 114 ++-------- .../server/methods/requestSubscriptionKeys.ts | 4 +- .../server/lib/sendNotificationsOnMessage.ts | 12 +- apps/meteor/client/lib/e2ee/helper.ts | 6 +- apps/meteor/client/lib/e2ee/logger.ts | 63 ++---- .../client/lib/e2ee/rocketchat.e2e.room.ts | 214 +++++++++--------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 97 ++++++-- .../root/hooks/loggedIn/useE2EEncryption.ts | 39 +++- 8 files changed, 271 insertions(+), 278 deletions(-) diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index a80b0ef2aafe5..3686f2a7b9007 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,5 +1,4 @@ -import { api } from '@rocket.chat/core-services'; -import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import { Subscriptions, Users } from '@rocket.chat/models'; import { ajv, validateUnauthorizedErrorResponse, @@ -13,7 +12,6 @@ import { } from '@rocket.chat/rest-typings'; import ExpiryMap from 'expiry-map'; -import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSuggestedGroupKey'; @@ -52,98 +50,32 @@ const E2eSetRoomKeyIdSchema = { const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); -const e2eEndpoints = API.v1 - .post( - 'e2e.setRoomKeyID', - { - authRequired: true, - body: isE2eSetRoomKeyIdProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), - }, - }, - - async function action() { - const { rid, keyID } = this.bodyParams; - - await setRoomKeyIDMethod(this.userId, rid, keyID); - - return API.v1.success(); - }, - ) - .get( - 'e2e.requestSubscriptionKeys', - { - authRequired: true, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), - }, - }, - async function action() { - // Get all encrypted rooms that the user is subscribed to and has no E2E key yet - const subscriptions = await Subscriptions.findByUserIdWithoutE2E(this.userId).toArray(); - const roomIds = subscriptions.map((subscription) => subscription.rid); - - // For all subscriptions without E2E key, get the rooms that have encryption enabled - const query = { - e2eKeyId: { - $exists: true, - }, - _id: { - $in: roomIds, +const e2eEndpoints = API.v1.post( + 'e2e.setRoomKeyID', + { + authRequired: true, + body: isE2eSetRoomKeyIdProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, }, - }; + required: ['success'], + }), + }, + }, - const rooms = Rooms.find(query); - for await (const room of rooms) { - void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - } + async function action() { + const { rid, keyID } = this.bodyParams; - return API.v1.success(); - }, - ) - .post( - 'e2e.resetOwnE2EKey', - { - twoFactorRequired: true, - authRequired: true, - body: ajv.compile({ type: 'object', properties: {}, additionalProperties: false }), - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - }), - }, - }, - async function action() { - if (!await resetUserE2EEncriptionKey(this.userId, false)) { - return API.v1.failure('failed-reset-e2e-password'); - } + await setRoomKeyIDMethod(this.userId, rid, keyID); - return API.v1.success(); - }, - ); + return API.v1.success(); + }, +); API.v1.addRoute( 'e2e.fetchMyKeys', diff --git a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts index d3737567be9f3..cf899a5d64ad7 100644 --- a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts +++ b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts @@ -34,9 +34,9 @@ Meteor.methods({ }; const rooms = Rooms.find(query); - for await (const room of rooms) { + await rooms.forEach((room) => { void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - } + }); return true; }, diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 25330d912aafd..d83c7445b4d72 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -28,7 +28,15 @@ type SubscriptionAggregation = { receiver: [Pick | null]; } & Pick< ISubscription, - 'desktopNotifications' | 'emailNotifications' | 'mobilePushNotifications' | 'muteGroupMentions' | 'name' | 'rid' | 'userHighlights' | 'u' + | 'desktopNotifications' + | 'emailNotifications' + | 'mobilePushNotifications' + | 'muteGroupMentions' + | 'name' + | 'rid' + | 'userHighlights' + | 'u' + | 'audioNotificationValue' >; type WithRequiredProperty = Type & { @@ -134,6 +142,7 @@ export const sendNotification = async ({ user: sender, message, room, + audioNotificationValue: subscription.audioNotificationValue, }); } @@ -244,6 +253,7 @@ const project = { 'receiver.statusConnection': 1, 'receiver.username': 1, 'receiver.settings.preferences.enableMobileRinging': 1, + 'audioNotificationValue': 1, }, } as const; diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index db7e5b85bbfdd..2ff51e8b9d148 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -25,7 +25,7 @@ export function toArrayBuffer(thing: any) { return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); } -export function joinVectorAndEncryptedData(vector: any, encryptedData: any) { +export function joinVectorAndEncryptedData(vector: Uint8Array, encryptedData: ArrayBuffer) { const cipherText = new Uint8Array(encryptedData); const output = new Uint8Array(vector.length + cipherText.length); output.set(vector, 0); @@ -69,6 +69,10 @@ export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, } export async function decryptAesGcm(iv: Uint8Array, key: CryptoKey, data: Uint8Array) { + if (iv.length !== 12) { + throw new Error('Invalid iv length for AES-GCM. Must be 12 bytes.'); + } + return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); } diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 0890657daa242..65f4786d61400 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,48 +1,15 @@ -class Logger { - level: 0 | 1 | 2 | 3 | 4 | 5; - - prefix: string; - - constructor(prefix: string, level: 0 | 1 | 2 | 3 | 4 | 5) { - this.level = level; - this.prefix = prefix; - } - - table(tabularData: T, properties?: readonly Extract[]) { - if (this.level <= 0) { - console.table(tabularData, properties); - } - } - - log(...data: unknown[]) { - if (this.level <= 0) { - console.log(this.prefix, ...data); - } - } - - info(...data: unknown[]) { - if (this.level <= 1) { - console.info(this.prefix, ...data); - } - } - - warn(...data: unknown[]) { - if (this.level <= 2) { - console.warn(this.prefix, ...data); - } - } - - error(...data: unknown[]) { - if (this.level <= 3) { - console.error(this.prefix, ...data); - } - } - - debug(...data: unknown[]) { - if (this.level <= 4) { - console.debug(this.prefix, ...data); - } - } -} - -export const logger = new Logger('E2EE', 0); +const noop = (): void => { + // do nothing +}; + + +export const createLogger = (level: 0 | 1 | 2 | 3 | 4 | 5 = 0) => { + return { + log: level <= 0 ? console.log.bind(console) : noop, + info: level <= 1 ? console.info.bind(console) : noop, + warn: level <= 2 ? console.warn.bind(console) : noop, + error: level <= 3 ? console.error.bind(console) : noop, + debug: level <= 4 ? console.debug.bind(console) : noop, + trace: level <= 5 ? console.trace.bind(console) : noop, + }; +} \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index aefc752f583d4..560fa292c295c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -26,7 +26,7 @@ import { encryptAesCbc, decryptAesCbc, } from './helper'; -import { logger } from './logger'; +import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; @@ -35,80 +35,47 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; +const logger = createLogger() + const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); -type Mutations = { - [K in E2ERoomState]: { - [K2 in E2ERoomState]?: true; - }; -}; +type Mutations = { [k in E2ERoomState]?: E2ERoomState[] }; const permitedMutations: Mutations = { - NOT_STARTED: { - ESTABLISHING: true, - DISABLED: true, - KEYS_RECEIVED: true, - }, - ESTABLISHING: { - READY: true, - KEYS_RECEIVED: true, - ERROR: true, - DISABLED: true, - WAITING_KEYS: true, - CREATING_KEYS: true, - }, - KEYS_RECEIVED: { - ESTABLISHING: true, - }, - CREATING_KEYS: { - READY: true, - KEYS_RECEIVED: true, - }, - READY: { - DISABLED: true, - CREATING_KEYS: true, - WAITING_KEYS: true, - KEYS_RECEIVED: true - }, - ERROR: { - KEYS_RECEIVED: true, - NOT_STARTED: true, - }, - WAITING_KEYS: { - KEYS_RECEIVED: true, - ERROR: true, - DISABLED: true, - }, - DISABLED: {}, + NOT_STARTED: ['ESTABLISHING', 'DISABLED', 'KEYS_RECEIVED'], + READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], + ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'], + WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], + ESTABLISHING: [ + 'READY', + 'KEYS_RECEIVED', + 'ERROR', + 'DISABLED', + 'WAITING_KEYS', + 'CREATING_KEYS', + ], }; const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { + // When state is undefined, allow it to be moved if (!currentState) { return nextState; } - if (permitedMutations[currentState][nextState]) { - return nextState; + if (currentState === nextState) { + return nextState === 'ERROR' ? 'ERROR' : false; } - return false; -}; - -const exportSessionKey = async (key: string): Promise => { - key = key.slice(12); - const decodedKey = Base64.decode(key); - - if (!e2e.privateKey) { - throw new Error('Private key not found'); + if (!(currentState in permitedMutations)) { + return nextState; } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); - return toString(decryptedKey); -}; + if (permitedMutations?.[currentState]?.includes(nextState)) { + return nextState; + } -const decryptSessionKey = async (key: string): Promise => { - return importAesGcmKey(JSON.parse(await exportSessionKey(key))); + return false; }; export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; @@ -121,9 +88,9 @@ export type EncryptedContent = { }; export class E2ERoom extends Emitter { - state: E2ERoomState | undefined = undefined; +state: E2ERoomState | undefined = undefined; - [PAUSED] = false; + [PAUSED]: boolean | undefined = undefined; [KEY_ID]: string; @@ -133,9 +100,11 @@ export class E2ERoom extends Emitter { typeOfRoom: string; + roomKeyId: string | undefined; + groupSessionKey: CryptoKey | undefined; - oldKeys: DecryptedGroupKey[] | undefined; + oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; sessionKeyExportedString: string | undefined; @@ -147,29 +116,32 @@ export class E2ERoom extends Emitter { this.userId = userId; this.roomId = room._id; this.typeOfRoom = room.t; + this.roomKeyId = room.e2eKeyId; this.once('READY', async () => { await this.decryptOldRoomKeys(); return this.decryptPendingMessages(); }); this.once('READY', () => this.decryptSubscription()); + this.on('STATE_CHANGED', (prev) => { + if (this.roomId === RoomManager.opened) { + this.info(`[PREV: ${prev}]`, 'State CHANGED'); + } + }); this.on('STATE_CHANGED', () => this.handshake()); - this.handshake(); - } - log(...msg: unknown[]): void { - if (!this.isCurrentRoom()) { - return; - } - logger.info(`[${this.roomId}]`, ...msg); + this.setState('NOT_STARTED'); } - error(...msg: unknown[]): void { - if (!this.isCurrentRoom()) { - return; - } - logger.error(`[${this.roomId}]`, ...msg); - } + warn = logger.warn.bind(logger); + + info = logger.info.bind(logger); + + error = logger.error.bind(logger); + + debug = logger.debug.bind(logger); + + trace = logger.trace.bind(logger); hasSessionKey(): boolean { return !!this.groupSessionKey; @@ -189,7 +161,7 @@ export class E2ERoom extends Emitter { } this.state = nextState; - this.log(currentState, '->', nextState); + this.info(currentState, '->', nextState); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -198,18 +170,26 @@ export class E2ERoom extends Emitter { return this.state === 'READY'; } + disable() { + this.setState('DISABLED'); + } + pause() { - this.log('PAUSED', this[PAUSED], '->', true); + this.trace('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - this.log('PAUSED', this[PAUSED], '->', false); + this.info('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } + keyReceived() { + this.setState('KEYS_RECEIVED'); + } + async shouldConvertSentMessages(message: { msg: string }) { if (!this.isReady() || this[PAUSED]) { return false; @@ -228,6 +208,10 @@ export class E2ERoom extends Emitter { return true; } + shouldConvertReceivedMessages() { + return this.isReady(); + } + isWaitingKeys() { return this.state === 'WAITING_KEYS'; } @@ -241,70 +225,86 @@ export class E2ERoom extends Emitter { } async decryptSubscription() { + this.info('decryptSubscriptions starting'); const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { - this.log('decryptSubscriptions nothing to do'); + this.info('decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - this.log('decryptSubscriptions updating lastMessage'); + this.info('decryptSubscriptions updating lastMessage', message, subscription.lastMessage); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - this.log('decryptSubscriptions Done'); + this.info('decryptSubscriptions Done'); } async decryptOldRoomKeys() { const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - this.log('decryptOldRoomKeys nothing to do'); + this.info('decryptOldRoomKeys nothing to do'); return; } - const keys: DecryptedGroupKey[] = []; + const keys = []; for await (const key of sub.oldRoomKeys) { try { - const k = await decryptSessionKey(key.E2EKey); + const k = await this.decryptSessionKey(key.E2EKey); keys.push({ ...key, E2EKey: k, }); } catch (e) { this.error( - `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping\n`, - JSON.stringify(e), + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, ); keys.push({ ...key, E2EKey: null }); } } this.oldKeys = keys; - this.log('decryptOldRoomKeys Done'); + this.info('decryptOldRoomKeys Done'); + } + + async decryptSessionKey(key: string) { + return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); + } + + async exportSessionKey(key: string) { + key = key.slice(12); + const decodedKey = Base64.decode(key); + + if (!e2e.privateKey) { + throw new Error('Private key not found'); + } + + const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + return toString(decryptedKey); } - async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']): Promise { - this.log('exportOldRoomKeys starting'); + async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { + this.info('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { - this.log('exportOldRoomKeys nothing to do'); + this.info('exportOldRoomKeys nothing to do'); return; } - const keys: EncryptedGroupKey[] = []; + const keys = []; for await (const key of oldKeys) { try { if (!key.E2EKey) { continue; } - const k = await exportSessionKey(key.E2EKey); + const k = await this.exportSessionKey(key.E2EKey); keys.push({ ...key, E2EKey: k, @@ -312,12 +312,12 @@ export class E2ERoom extends Emitter { } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e, + e ); } } - this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + this.info(`exportOldRoomKeys Done: ${keys.length} keys exported`); return keys; } @@ -375,7 +375,7 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - this.log('Requesting room key'); + this.info('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { this.setState('ERROR'); @@ -413,7 +413,7 @@ export class E2ERoom extends Emitter { async handleSuggestedKey(suggestedKey: string) { if (await this.importGroupKey(suggestedKey)) { - this.log('Imported valid E2E suggested key'); + this.info('Imported valid E2E suggested key'); await this.acceptSuggestedKey(); this.setState('KEYS_RECEIVED'); } else { @@ -423,12 +423,12 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string): Promise { - this.log('Importing room key ->', this.roomId); + this.trace('Importing room key ->', this.roomId); // Get existing group key const keyID = groupKey.slice(0, 12); if (this.keyID === keyID) { - this.log('Group key is already imported'); + this.info('Group key is already imported'); return true; } @@ -450,7 +450,7 @@ export class E2ERoom extends Emitter { // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } const jwk = JSON.parse(this.sessionKeyExportedString); @@ -477,13 +477,18 @@ export class E2ERoom extends Emitter { } async createGroupKey() { - this.log('Creating room key'); + this.info('Creating room key'); try { await this.createNewGroupKey(); - await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID }); + await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); if (myKey) { + await sdk.rest.post('/v1/e2e.updateGroupKey', { + rid: this.roomId, + uid: this.userId, + key: myKey, + }); await this.encryptKeyForOtherParticipants(); } } catch (error) { @@ -493,7 +498,7 @@ export class E2ERoom extends Emitter { } async resetRoomKey() { - this.log('Resetting room key'); + this.info('Resetting room key'); if (!e2e.publicKey) { this.error('Cannot reset room key. No public key found.'); return; @@ -506,7 +511,7 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - this.log(`Room key reset done for room ${this.roomId}`); + this.info(`Room key reset done for room ${this.roomId}`); return e2eNewKeys; } catch (error) { @@ -516,7 +521,7 @@ export class E2ERoom extends Emitter { } onRoomKeyReset(keyID: string) { - this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + this.info(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -606,6 +611,9 @@ export class E2ERoom extends Emitter { // Encrypt session key for this user with his/her public key try { + if (!this.sessionKeyExportedString) { + return this.error('No session key found.'); + } const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 76057d54642db..56c549195f44a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,5 +1,3 @@ - - import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; import type { EncryptedKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; @@ -11,7 +9,7 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -import { logger } from './logger'; +import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; import { getUserAvatarURL } from '../../../app/utils/client'; @@ -29,6 +27,8 @@ import { settings } from '../settings'; import { dispatchToastMessage } from '../toast'; import { mapMessageFromApi } from '../utils/mapMessageFromApi'; +const logger = createLogger(); + let failedToDecodeKey = false; const ROOM_KEY_EXCHANGE_SIZE = 10; @@ -48,8 +48,9 @@ const waitForRoom = (rid: IRoom['_id']): Promise => }); }); -const isRoomWithSuggestedKey = (sub: T): sub is T & { E2ESuggestedKey: string, E2EKey?: undefined } => - typeof sub.E2ESuggestedKey === 'string' && !sub.E2EKey; +const isRoomWithSuggestedKey = ( + sub: T, +): sub is T & { E2ESuggestedKey: string; E2EKey?: undefined } => typeof sub.E2ESuggestedKey === 'string' && !sub.E2EKey; const isRoomEncrypted = (sub: T): sub is T & { encrypted: true } => sub.encrypted === true; @@ -58,18 +59,18 @@ const filterSubscriptions = (array: readonly T[], size: number): T[] => { - if (size >= array.length) return [...array]; - const arr = array.slice(); // don't mutate caller - for (let i = 0; i < size; i++) { - const r = i + Math.floor(Math.random() * (arr.length - i)); // random index in [i, end) - [arr[i], arr[r]] = [arr[r], arr[i]]; - } - return arr.slice(0, size); -} + if (size >= array.length) return [...array]; + const arr = array.slice(); // don't mutate caller + for (let i = 0; i < size; i++) { + const r = i + Math.floor(Math.random() * (arr.length - i)); // random index in [i, end) + [arr[i], arr[r]] = [arr[r], arr[i]]; + } + return arr.slice(0, size); +}; class E2E extends Emitter<{ READY: void; - E2E_STATE_CHANGED: { prevState: E2EEState; nextState: E2EEState }; + E2E_STATE_CHANGED: { prevState: E2EEState | undefined; nextState: E2EEState }; SAVE_PASSWORD: void; DISABLED: void; NOT_STARTED: void; @@ -91,7 +92,7 @@ class E2E extends Emitter<{ private keyDistributionInterval: ReturnType | null; - private state: E2EEState = 'NOT_STARTED'; + private state: E2EEState | undefined = undefined; private e2ee: E2EE; @@ -133,8 +134,18 @@ class E2E extends Emitter<{ this.on('ERROR', () => { this.unsubscribeFromSubscriptions?.(); }); + + this.setState('NOT_STARTED'); } + warn = logger.warn.bind(logger); + + info = logger.info.bind(logger); + + error = logger.error.bind(logger); + + debug = logger.debug.bind(logger); + getState() { return this.state; } @@ -158,13 +169,56 @@ class E2E extends Emitter<{ } async onSubscriptionChanged(sub: ISubscription) { + this.info('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; } - await this.room(sub.rid, async (e2eRoom) => { - await e2eRoom.handleSubscriptionChanged(sub); + const e2eRoom = await this.getInstanceByRoomId(sub.rid); + if (!e2eRoom) { + return; + } + + if (sub.E2ESuggestedKey) { + if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { + await this.acceptSuggestedKey(sub.rid); + e2eRoom.keyReceived(); + } else { + console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + await this.rejectSuggestedKey(sub.rid); + } + } + + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + + // Cover private groups and direct messages + if (!e2eRoom.isSupportedRoomType(sub.t)) { + e2eRoom.disable(); + return; + } + + if (sub.E2EKey && e2eRoom.isWaitingKeys()) { + e2eRoom.keyReceived(); + return; + } + + if (!e2eRoom.isReady()) { + return; + } + + await e2eRoom.decryptSubscription(); + } + + async acceptSuggestedKey(rid: string): Promise { + await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { + rid, + }); + } + + async rejectSuggestedKey(rid: string): Promise { + await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { + rid, }); } @@ -181,7 +235,7 @@ class E2E extends Emitter<{ const excess = instatiated.difference(subscribed); if (excess.size) { - logger.log('Unsubscribing from excess instances', excess); + this.info('Unsubscribing from excess instances', excess); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -419,7 +473,7 @@ class E2E extends Emitter<{ } async requestSubscriptionKeys(): Promise { - await sdk.rest.get('/v1/e2e.requestSubscriptionKeys'); + await sdk.call('e2e.requestSubscriptionKeys'); } async encodePrivateKey(privateKey: string, password: string): Promise { @@ -566,7 +620,7 @@ class E2E extends Emitter<{ const data = await e2eRoom.decrypt({ algorithm: 'rc.v2.aes-gcm-sha2', - ciphertext: pinnedMessage + ciphertext: pinnedMessage, }); if (!data) { @@ -593,8 +647,7 @@ class E2E extends Emitter<{ } async decryptSubscriptions(): Promise { - filterSubscriptions(isRoomEncrypted) - .forEach((subscription) => this.decryptSubscription(subscription._id)); + filterSubscriptions(isRoomEncrypted).forEach((subscription) => this.decryptSubscription(subscription._id)); } openAlert(config: Omit): void { diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 0f79d099ad7db..c04cfaf6eb92c 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -1,12 +1,12 @@ -import { isE2EEPinnedMessage, type IMessage } from '@rocket.chat/core-typings'; +import { isE2EEPinnedMessage, type IRoom, type IMessage } from '@rocket.chat/core-typings'; import { useUserId, useSetting, useRouter, useLayout, useUser } from '@rocket.chat/ui-contexts'; import { useEffect, useRef } from 'react'; import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser'; import { e2e } from '../../../../lib/e2ee'; -import { logger } from '../../../../lib/e2ee/logger'; import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived'; +import { Rooms } from '../../../../stores'; import { useE2EEState } from '../../../room/hooks/useE2EEState'; export const useE2EEncryption = () => { @@ -18,19 +18,20 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - logger.warn('Not logged in'); + e2e.info('Not logged in'); return; } if (!window.crypto) { - logger.error('No crypto support'); + e2e.error('No crypto support'); return; } if (enabled && !adminEmbedded) { - logger.debug('enabled starting client'); + e2e.info('E2E enabled starting client'); e2e.startClient(); } else { + e2e.info('E2E disabled'); e2e.setState('DISABLED'); e2e.closeAlert(); } @@ -47,18 +48,18 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - logger.info('Not ready'); + e2e.info('Not ready'); return; } if (listenersAttachedRef.current) { - logger.warn('Listeners already attached'); + e2e.info('Listeners already attached'); return; } const offClientMessageReceived = onClientMessageReceived.use(async (msg) => { const e2eRoom = await e2e.getInstanceByRoomId(msg.rid); - if (!e2eRoom?.isReady()) { + if (!e2eRoom?.shouldConvertReceivedMessages()) { return msg; } @@ -77,6 +78,24 @@ export const useE2EEncryption = () => { return message; } + // e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be + // refactored to avoid waiting for the room again + const subscription = await new Promise((resolve) => { + const room = Rooms.state.get(message.rid); + + if (room) resolve(room); + + const unsubscribe = Rooms.use.subscribe((state) => { + const room = state.get(message.rid); + if (room) { + unsubscribe(); + resolve(room); + } + }); + }); + + subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message); if (!shouldConvertSentMessages) return message; @@ -101,10 +120,10 @@ export const useE2EEncryption = () => { }); listenersAttachedRef.current = true; - logger.info('Listeners attached'); + e2e.info('Listeners attached'); return () => { - logger.info('Not ready'); + e2e.info('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; From d9886dd14b3c0f3cd0687ea83913cd5aed207632 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 9 Sep 2025 13:54:28 -0300 Subject: [PATCH 140/251] revert some changes --- apps/meteor/client/lib/e2ee/helper.ts | 16 ++++++++-------- .../client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- .../views/hooks/useResetE2EPasswordMutation.ts | 4 ++-- .../room/ImageGallery/hooks/useImagesList.ts | 2 +- .../RoomFiles/hooks/useFilesList.ts | 2 +- .../client/views/room/hooks/useE2EERoomState.ts | 3 ++- .../client/views/room/hooks/useE2EEState.ts | 4 ++-- 7 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 2ff51e8b9d148..bf6bf114fabd9 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -139,15 +139,15 @@ export async function importAesCbcKey(keyData: JsonWebKey, keyUsages: ReadonlyAr /** * Imports an AES-GCM key from JWK format. */ -export async function importAesGcmKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { +export function importAesGcmKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { return crypto.subtle.importKey('jwk', keyData, { name: 'AES-GCM' }, true, keyUsages); } -export async function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArray = ['deriveKey']) { +export function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArray = ['deriveKey']) { return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); } -export async function deriveKey(salt: Uint8Array, baseKey: CryptoKey) { +export function deriveKey(salt: Uint8Array, baseKey: CryptoKey) { return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, baseKey, @@ -157,11 +157,11 @@ export async function deriveKey(salt: Uint8Array, baseKey: CryptoKe ); } -export async function readFileAsArrayBuffer(file: File) { - return new Promise((resolve, reject) => { +export function readFileAsArrayBuffer(file: File) { + return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (evt) => { - resolve(evt.target?.result); + resolve(evt.target?.result as ArrayBuffer); }; reader.onerror = (evt) => { reject(evt); @@ -170,14 +170,14 @@ export async function readFileAsArrayBuffer(file: File) { }); } -export async function createSha256HashFromText(data: any) { +export async function createSha256HashFromText(data: string) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } -export async function sha256HashFromArrayBuffer(arrayBuffer: any) { +export async function sha256HashFromArrayBuffer(arrayBuffer: ArrayBuffer) { const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer))); return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 560fa292c295c..d7e87345d89de 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -630,7 +630,7 @@ state: E2ERoomState | undefined = undefined; const fileArrayBuffer = await readFileAsArrayBuffer(file); - const hash = await sha256HashFromArrayBuffer(new Uint8Array(fileArrayBuffer)); + const hash = await sha256HashFromArrayBuffer(fileArrayBuffer); const vector = crypto.getRandomValues(new Uint8Array(16)); const key = await generateAESCTRKey(); diff --git a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts index 83d59732dd670..652c4e8b02143 100644 --- a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts +++ b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts @@ -1,4 +1,4 @@ -import { useEndpoint, useLogout, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useLogout, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { MutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -7,7 +7,7 @@ export const useResetE2EPasswordMutation = ({ options }: { options?: MutationOpt const { t } = useTranslation(); const logout = useLogout(); - const resetE2eKey = useEndpoint('POST', '/v1/e2e.resetOwnE2EKey'); + const resetE2eKey = useMethod('e2e.resetOwnE2EKey'); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ diff --git a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts index 217d561545d84..b753a5a7cc33a 100644 --- a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts +++ b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts @@ -51,7 +51,7 @@ export const useImagesList = ( for await (const file of items) { if (file.rid && file.content) { const e2eRoom = await e2e.getInstanceByRoomId(file.rid); - if (e2eRoom?.isReady()) { + if (e2eRoom?.shouldConvertReceivedMessages()) { const decrypted = await e2e.decryptFileContent(file); const key = Base64.encode( JSON.stringify({ diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts index 0bbf7159e68da..35555709c6362 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts @@ -65,7 +65,7 @@ export const useFilesList = ( for await (const file of items) { if (file.rid && file.content) { const e2eRoom = await e2e.getInstanceByRoomId(file.rid); - if (e2eRoom?.isReady()) { + if (e2eRoom?.shouldConvertReceivedMessages()) { const decrypted = await e2e.decryptFileContent(file); const key = Base64.encode( JSON.stringify({ diff --git a/apps/meteor/client/views/room/hooks/useE2EERoomState.ts b/apps/meteor/client/views/room/hooks/useE2EERoomState.ts index dbecb983b026a..3ccf8b712bda6 100644 --- a/apps/meteor/client/views/room/hooks/useE2EERoomState.ts +++ b/apps/meteor/client/views/room/hooks/useE2EERoomState.ts @@ -1,6 +1,7 @@ import { useMemo, useSyncExternalStore } from 'react'; import { useE2EERoom } from './useE2EERoom'; +import type { E2ERoomState } from '../../../lib/e2ee/E2ERoomState'; export const useE2EERoomState = (rid: string) => { const e2eRoom = useE2EERoom(rid); @@ -9,7 +10,7 @@ export const useE2EERoomState = (rid: string) => { () => [ (callback: () => void): (() => void) => (e2eRoom ? e2eRoom.onStateChange(callback) : () => undefined), - () => (e2eRoom ? e2eRoom.getState() : undefined), + (): E2ERoomState | undefined => (e2eRoom ? e2eRoom.getState() : undefined), ] as const, [e2eRoom], ); diff --git a/apps/meteor/client/views/room/hooks/useE2EEState.ts b/apps/meteor/client/views/room/hooks/useE2EEState.ts index 2267e4b327fd5..78f50e42a1a18 100644 --- a/apps/meteor/client/views/room/hooks/useE2EEState.ts +++ b/apps/meteor/client/views/room/hooks/useE2EEState.ts @@ -4,6 +4,6 @@ import { e2e } from '../../../lib/e2ee'; import type { E2EEState } from '../../../lib/e2ee/E2EEState'; const subscribe = (callback: () => void): (() => void) => e2e.on('E2E_STATE_CHANGED', callback); -const getSnapshot = (): E2EEState => e2e.getState(); +const getSnapshot = (): E2EEState | undefined => e2e.getState(); -export const useE2EEState = (): E2EEState => useSyncExternalStore(subscribe, getSnapshot); +export const useE2EEState = (): E2EEState | undefined => useSyncExternalStore(subscribe, getSnapshot); From 2bf1748dbb783eaa73409b638cdc16096e04d244 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 9 Sep 2025 13:58:56 -0300 Subject: [PATCH 141/251] fix unit test --- packages/e2ee/src/__tests__/vector.spec.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/e2ee/src/__tests__/vector.spec.ts b/packages/e2ee/src/__tests__/vector.spec.ts index 3e2493a39e3d4..311ec734c0988 100644 --- a/packages/e2ee/src/__tests__/vector.spec.ts +++ b/packages/e2ee/src/__tests__/vector.spec.ts @@ -4,8 +4,8 @@ import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from '../vect test('joinVectorAndEncryptedData and splitVectorAndEncryptedData', () => { const [vector, encryptedData] = splitVectorAndEncryptedData( joinVectorAndEncryptedData( - new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), - new Uint8Array([17, 18, 19, 20]).buffer, + new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + new Uint8Array([13, 14, 15, 16]).buffer, ), ); expect(vector).toMatchInlineSnapshot(` @@ -22,18 +22,14 @@ test('joinVectorAndEncryptedData and splitVectorAndEncryptedData', () => { 10, 11, 12, - 13, - 14, - 15, - 16, ] `); expect(encryptedData).toMatchInlineSnapshot(` Uint8Array [ - 17, - 18, - 19, - 20, + 13, + 14, + 15, + 16, ] `); }); From e37a774e88dcdc923feee403ba49faf8db0e3cf6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 9 Sep 2025 17:03:26 -0300 Subject: [PATCH 142/251] revert some stuff --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 301 +++++------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 461 +++++++++++------- .../views/account/security/EndToEnd.tsx | 5 +- .../root/hooks/loggedIn/useE2EEncryption.ts | 14 +- packages/e2ee/src/index.ts | 2 +- 5 files changed, 407 insertions(+), 376 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index d7e87345d89de..e469e4807832d 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -23,8 +23,6 @@ import { generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, - encryptAesCbc, - decryptAesCbc, } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -35,7 +33,7 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; -const logger = createLogger() +const { info: log, error: logError } = createLogger(); const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -78,17 +76,8 @@ const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERo return false; }; -export type EncryptedGroupKey = { E2EKey: string; e2eKeyId: string; ts: Date }; -export type DecryptedGroupKey = { E2EKey: CryptoKey | null; e2eKeyId: string; ts: Date }; - -export type EncryptionAlgorithm = 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2'; -export type EncryptedContent = { - algorithm: EncryptionAlgorithm; - ciphertext: string; -}; - export class E2ERoom extends Emitter { -state: E2ERoomState | undefined = undefined; + state: E2ERoomState | undefined = undefined; [PAUSED]: boolean | undefined = undefined; @@ -125,7 +114,7 @@ state: E2ERoomState | undefined = undefined; this.once('READY', () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === RoomManager.opened) { - this.info(`[PREV: ${prev}]`, 'State CHANGED'); + this.log(`[PREV: ${prev}]`, 'State CHANGED'); } }); this.on('STATE_CHANGED', () => this.handshake()); @@ -133,25 +122,27 @@ state: E2ERoomState | undefined = undefined; this.setState('NOT_STARTED'); } - warn = logger.warn.bind(logger); - - info = logger.info.bind(logger); - - error = logger.error.bind(logger); - - debug = logger.debug.bind(logger); + log(...msg: unknown[]) { + if (this.roomId === RoomManager.opened) { + log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + } + } - trace = logger.trace.bind(logger); + error(...msg: unknown[]) { + if (this.roomId === RoomManager.opened) { + logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + } + } - hasSessionKey(): boolean { + hasSessionKey() { return !!this.groupSessionKey; } - getState(): E2ERoomState | undefined { + getState() { return this.state; } - setState(requestedState: E2ERoomState): void { + setState(requestedState: E2ERoomState) { const currentState = this.state; const nextState = filterMutation(currentState, requestedState); @@ -161,7 +152,7 @@ state: E2ERoomState | undefined = undefined; } this.state = nextState; - this.info(currentState, '->', nextState); + this.log(currentState, '->', nextState); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -170,18 +161,30 @@ state: E2ERoomState | undefined = undefined; return this.state === 'READY'; } + isDisabled() { + return this.state === 'DISABLED'; + } + + enable() { + if (this.state === 'READY') { + return; + } + + this.setState('READY'); + } + disable() { this.setState('DISABLED'); } pause() { - this.trace('PAUSED', this[PAUSED], '->', true); + this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - this.info('PAUSED', this[PAUSED], '->', false); + this.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } @@ -225,32 +228,31 @@ state: E2ERoomState | undefined = undefined; } async decryptSubscription() { - this.info('decryptSubscriptions starting'); const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); if (subscription?.lastMessage?.t !== 'e2e') { - this.info('decryptSubscriptions nothing to do'); + this.log('decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - this.info('decryptSubscriptions updating lastMessage', message, subscription.lastMessage); + this.log('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - this.info('decryptSubscriptions Done'); + this.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - this.info('decryptOldRoomKeys nothing to do'); + this.log('decryptOldRoomKeys nothing to do'); return; } @@ -271,29 +273,13 @@ state: E2ERoomState | undefined = undefined; } this.oldKeys = keys; - this.info('decryptOldRoomKeys Done'); - } - - async decryptSessionKey(key: string) { - return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); - } - - async exportSessionKey(key: string) { - key = key.slice(12); - const decodedKey = Base64.decode(key); - - if (!e2e.privateKey) { - throw new Error('Private key not found'); - } - - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); - return toString(decryptedKey); + this.log('decryptOldRoomKeys Done'); } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - this.info('exportOldRoomKeys starting'); + this.log('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { - this.info('exportOldRoomKeys nothing to do'); + this.log('exportOldRoomKeys nothing to do'); return; } @@ -312,12 +298,11 @@ state: E2ERoomState | undefined = undefined; } catch (e) { this.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e ); } } - this.info(`exportOldRoomKeys Done: ${keys.length} keys exported`); + this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); return keys; } @@ -328,18 +313,6 @@ state: E2ERoomState | undefined = undefined; ); } - async acceptSuggestedKey(): Promise { - await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { - rid: this.roomId, - }); - } - - async rejectSuggestedKey(): Promise { - await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { - rid: this.roomId, - }); - } - // Initiates E2E Encryption async handshake() { if (!e2e.isReady()) { @@ -375,65 +348,47 @@ state: E2ERoomState | undefined = undefined; } this.setState('WAITING_KEYS'); - this.info('Requesting room key'); + this.log('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { + this.error('Error during handshake: ', error); this.setState('ERROR'); - this.error('Error requesting group key: ', error); } } - isSupportedRoomType(type: string): boolean { + isSupportedRoomType(type: string) { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } - async handleSubscriptionChanged(sub: ISubscription) { - if (sub.E2ESuggestedKey) { - await this.handleSuggestedKey(sub.E2ESuggestedKey); - } - sub.encrypted ? this.resume() : this.pause(); - - // Cover private groups and direct messages - if (!this.isSupportedRoomType(sub.t)) { - this.setState('DISABLED'); - return; - } + async decryptSessionKey(key: string) { + return importAesGcmKey(JSON.parse(await this.exportSessionKey(key))); + } - if (sub.E2EKey && this.isWaitingKeys()) { - this.setState('KEYS_RECEIVED'); - return; - } + async exportSessionKey(key: string) { + key = key.slice(12); + const decodedKey = Base64.decode(key); - if (!this.isReady()) { - return; + if (!e2e.privateKey) { + throw new Error('Private key not found'); } - await this.decryptSubscription(); - } - - async handleSuggestedKey(suggestedKey: string) { - if (await this.importGroupKey(suggestedKey)) { - this.info('Imported valid E2E suggested key'); - await this.acceptSuggestedKey(); - this.setState('KEYS_RECEIVED'); - } else { - this.error('Invalid E2ESuggestedKey, rejecting', suggestedKey); - await this.rejectSuggestedKey(); - } + const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + return toString(decryptedKey); } - async importGroupKey(groupKey: string): Promise { - this.trace('Importing room key ->', this.roomId); + async importGroupKey(groupKey: string) { + this.log('Importing room key ->', this.roomId); // Get existing group key - const keyID = groupKey.slice(0, 12); + const keyID = groupKey.slice(0, 36); + groupKey = groupKey.slice(36); + const decodedGroupKey = Base64.decode(groupKey); - if (this.keyID === keyID) { - this.info('Group key is already imported'); + if (this.keyID === keyID && this.groupSessionKey) { + this.log('KeyID matches and session key already exists. Nothing to do'); return true; } - groupKey = groupKey.slice(12); - const decodedGroupKey = Base64.decode(groupKey); + // Decrypt obtained encrypted session key try { @@ -443,25 +398,23 @@ state: E2ERoomState | undefined = undefined; const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - this.error('Error decrypting group key: ', error); + this.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey, }); return false; } // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = this.roomKeyId || keyID || crypto.randomUUID(); } - const jwk = JSON.parse(this.sessionKeyExportedString); - // Import session key for use. try { - const key = await importAesGcmKey(jwk); + const key = await importAesGcmKey(JSON.parse(this.sessionKeyExportedString!)); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - this.error('Error importing group key: ', error, JSON.stringify(jwk)); + this.error('Error importing group key: ', error); return false; } @@ -471,13 +424,13 @@ state: E2ERoomState | undefined = undefined; async createNewGroupKey() { this.groupSessionKey = await generateAesGcmKey(); - const sessionKeyExported = await exportJWKKey(this.groupSessionKey); + const sessionKeyExported = await exportJWKKey(this.groupSessionKey!); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); - this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = crypto.randomUUID(); } async createGroupKey() { - this.info('Creating room key'); + this.log('Creating room key'); try { await this.createNewGroupKey(); @@ -498,7 +451,7 @@ state: E2ERoomState | undefined = undefined; } async resetRoomKey() { - this.info('Resetting room key'); + this.log('Resetting room key'); if (!e2e.publicKey) { this.error('Cannot reset room key. No public key found.'); return; @@ -511,7 +464,7 @@ state: E2ERoomState | undefined = undefined; const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - this.info(`Room key reset done for room ${this.roomId}`); + this.log(`Room key reset done for room ${this.roomId}`); return e2eNewKeys; } catch (error) { @@ -521,7 +474,7 @@ state: E2ERoomState | undefined = undefined; } onRoomKeyReset(keyID: string) { - this.info(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -535,7 +488,7 @@ state: E2ERoomState | undefined = undefined; try { const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); - const { users } = await sdk.rest.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid: this.roomId }); + const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); if (!users.length) { return; @@ -550,12 +503,12 @@ state: E2ERoomState | undefined = undefined; }[] > = { [this.roomId]: [] }; for await (const user of users) { - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); if (!encryptedGroupKey) { return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key!, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; @@ -611,9 +564,6 @@ state: E2ERoomState | undefined = undefined; // Encrypt session key for this user with his/her public key try { - if (!this.sessionKeyExportedString) { - return this.error('No session key found.'); - } const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; @@ -656,27 +606,24 @@ state: E2ERoomState | undefined = undefined; }; } + // Decrypt uploaded encrypted files. I/O is in arraybuffers. + async decryptFile(file: Uint8Array, key: JsonWebKey, iv: string) { + const ivArray = Base64.decode(iv); + const cryptoKey = await window.crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']); + + return window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file); + } + // Encrypts messages - async encryptText( - data: Uint8Array, - algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-gcm-sha2' = 'rc.v2.aes-gcm-sha2', - ): Promise { - const ivLength = algorithm === 'rc.v1.aes-sha2' ? 16 : 12 as const; - const vector = crypto.getRandomValues(new Uint8Array(ivLength)); + async encryptText(data: Uint8Array) { + const vector = crypto.getRandomValues(new Uint8Array(12)); try { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = - algorithm === 'rc.v1.aes-sha2' - ? await encryptAesCbc(vector, this.groupSessionKey, data) - : await encryptAesGcm(vector, this.groupSessionKey, data); - const ciphertext = this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); - return { - algorithm, - ciphertext, - }; + const result = await encryptAesGcm(vector, this.groupSessionKey, data); + return this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); } catch (error) { this.error('Error encrypting message: ', error); throw error; @@ -689,8 +636,10 @@ state: E2ERoomState | undefined = undefined; ) { const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted)); - const encrypted = await this.encryptText(data, 'rc.v2.aes-gcm-sha2'); - return encrypted; + return { + algorithm: 'rc.v1.aes-sha2', + ciphertext: await this.encryptText(data), + }; } // Helper function for encryption of content @@ -710,7 +659,11 @@ state: E2ERoomState | undefined = undefined; } // Helper function for encryption of messages - async encrypt(message: { _id: IMessage['_id']; msg: IMessage['msg'] }): Promise { + encrypt(message: IMessage) { + if (!this.isSupportedRoomType(this.typeOfRoom)) { + return; + } + if (!this.groupSessionKey) { throw new Error(t('E2E_Invalid_Key')); } @@ -726,28 +679,15 @@ state: E2ERoomState | undefined = undefined; }), ); - const encrypted = await this.encryptText(data, 'rc.v2.aes-gcm-sha2'); - - return encrypted.ciphertext; + return this.encryptText(data); } async decryptContent(data: T) { - if (!data.content) { - return data; + if (data.content && data.content.algorithm === 'rc.v1.aes-sha2') { + const content = await this.decrypt(data.content.ciphertext); + Object.assign(data, content); } - const { - content: { algorithm, ciphertext }, - } = data; - - if (algorithm !== 'rc.v2.aes-gcm-sha2' && algorithm !== 'rc.v1.aes-sha2') { - this.error('Unknown encryption algorithm: ', algorithm); - return data; - } - - const decryptedContent = await this.decrypt({ algorithm, ciphertext }); - Object.assign(data, decryptedContent); - return data; } @@ -758,7 +698,7 @@ state: E2ERoomState | undefined = undefined; } if (message.msg) { - const data = await this.decrypt({ algorithm: 'rc.v2.aes-gcm-sha2', ciphertext: message.msg }); + const data = await this.decrypt(message.msg); if (data?.text) { message.msg = data.text; @@ -773,26 +713,20 @@ state: E2ERoomState | undefined = undefined; }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array, algorithm: EncryptionAlgorithm) { - if (algorithm === 'rc.v2.aes-gcm-sha2') { - const result = await decryptAesGcm(vector, key, cipherText); - return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); - } - - const result = await decryptAesCbc(vector, key, cipherText); + async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { + const result = await decryptAesGcm(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } - async decrypt(content: EncryptedContent) { - const ivLength = content.algorithm === 'rc.v2.aes-gcm-sha2' ? 12 : 16 as const; - const keyID = content.ciphertext.slice(0, 12); - content.ciphertext = content.ciphertext.slice(12); + async decrypt(message: string) { + const keyID = message.slice(0, 36); + message = message.slice(36); - const [vector, ciphertext] = splitVectorAndEncryptedData(Base64.decode(content.ciphertext), ivLength); + const [vector, cipherText] = splitVectorAndEncryptedData(Base64.decode(message), 12); let oldKey = null; if (keyID !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === keyID); + const oldRoomKey = this.oldKeys?.find((key: any) => key.e2eKeyId === keyID); // Messages already contain a keyID stored with them // That means that if we cannot find a keyID for the key the message has preppended to // The message is indecipherable. @@ -803,9 +737,9 @@ state: E2ERoomState | undefined = undefined; if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, ciphertext, content.algorithm); + return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - this.error('Error decrypting message: ', error, content); + this.error('Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; } } @@ -814,18 +748,27 @@ state: E2ERoomState | undefined = undefined; try { if (oldKey) { - return await this.doDecrypt(vector, oldKey, ciphertext, content.algorithm); + return await this.doDecrypt(vector, oldKey, cipherText); } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, ciphertext, content.algorithm); + return await this.doDecrypt(vector, this.groupSessionKey, cipherText); } catch (error) { - this.error('Error decrypting message: ', error, content); + this.error('Error decrypting message: ', error, message); return { msg: t('E2E_Key_Error') }; } } + provideKeyToUser(keyId: string) { + if (this.keyID !== keyId) { + return; + } + + void this.encryptKeyForOtherParticipants(); + this.setState('READY'); + } + onStateChange(cb: () => void) { this.on('STATE_CHANGED', cb); return () => this.off('STATE_CHANGED', cb); @@ -852,8 +795,4 @@ state: E2ERoomState | undefined = undefined; return usersWithKeys; } - - isCurrentRoom() { - return this.roomId === RoomManager.opened; - } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 56c549195f44a..5a6d94d159ba2 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,14 +1,30 @@ +import QueryString from 'querystring'; +import URL from 'url'; + import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import type { EncryptedKeyPair, RemoteKeyPair } from '@rocket.chat/e2ee'; -import E2EE from '@rocket.chat/e2ee'; +import { generateMnemonicPhrase } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; -import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import EJSON from 'ejson'; +import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; +import { + toString, + toArrayBuffer, + joinVectorAndEncryptedData, + splitVectorAndEncryptedData, + encryptAesGcm, + decryptAesGcm, + generateRSAKey, + exportJWKKey, + importRSAKey, + importRawKey, + deriveKey, +} from './helper'; import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; @@ -27,57 +43,18 @@ import { settings } from '../settings'; import { dispatchToastMessage } from '../toast'; import { mapMessageFromApi } from '../utils/mapMessageFromApi'; -const logger = createLogger(); - let failedToDecodeKey = false; -const ROOM_KEY_EXCHANGE_SIZE = 10; - -const waitForRoom = (rid: IRoom['_id']): Promise => - new Promise((resolve) => { - const room = Rooms.state.get(rid); - - if (room) resolve(room); - - const unsubscribe = Rooms.use.subscribe((state) => { - const room = state.get(rid); - if (room) { - unsubscribe(); - resolve(room); - } - }); - }); - -const isRoomWithSuggestedKey = ( - sub: T, -): sub is T & { E2ESuggestedKey: string; E2EKey?: undefined } => typeof sub.E2ESuggestedKey === 'string' && !sub.E2EKey; - -const isRoomEncrypted = (sub: T): sub is T & { encrypted: true } => sub.encrypted === true; +const { info: log, error: logError } = createLogger(); -const filterSubscriptions = (filter: (sub: SubscriptionWithRoom) => sub is T) => { - return Subscriptions.state.filter(filter); +type KeyPair = { + public_key: string | null; + private_key: string | null; }; -const sampleSize = (array: readonly T[], size: number): T[] => { - if (size >= array.length) return [...array]; - const arr = array.slice(); // don't mutate caller - for (let i = 0; i < size; i++) { - const r = i + Math.floor(Math.random() * (arr.length - i)); // random index in [i, end) - [arr[i], arr[r]] = [arr[r], arr[i]]; - } - return arr.slice(0, size); -}; +const ROOM_KEY_EXCHANGE_SIZE = 10; -class E2E extends Emitter<{ - READY: void; - E2E_STATE_CHANGED: { prevState: E2EEState | undefined; nextState: E2EEState }; - SAVE_PASSWORD: void; - DISABLED: void; - NOT_STARTED: void; - ERROR: void; - LOADING_KEYS: void; - ENTER_PASSWORD: void; -}> { +class E2E extends Emitter { private started: boolean; private instancesByRoomId: Record; @@ -92,27 +69,16 @@ class E2E extends Emitter<{ private keyDistributionInterval: ReturnType | null; - private state: E2EEState | undefined = undefined; - - private e2ee: E2EE; + private state: E2EEState; constructor() { super(); this.started = false; this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.e2ee = new E2EE({ - userId: () => Meteor.userId(), - fetchMyKeys: () => sdk.rest.get('/v1/e2e.fetchMyKeys'), - persistKeys: (keys, force) => - sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { - ...keys, - force, - }), - }); this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { - logger.log(`${prevState} -> ${nextState}`); + this.log(`${prevState} -> ${nextState}`); }); this.on('READY', async () => { @@ -138,38 +104,42 @@ class E2E extends Emitter<{ this.setState('NOT_STARTED'); } - warn = logger.warn.bind(logger); - - info = logger.info.bind(logger); - - error = logger.error.bind(logger); + log(...msg: unknown[]) { + log('E2E', ...msg); + } - debug = logger.debug.bind(logger); + error(...msg: unknown[]) { + logError('E2E', ...msg); + } getState() { return this.state; } + isEnabled(): boolean { + return this.state !== 'DISABLED'; + } + isReady(): boolean { // Save_Password state is also a ready state for E2EE return this.state === 'READY' || this.state === 'SAVE_PASSWORD'; } async onE2EEReady() { - logger.log('startClient -> Done'); + this.log('startClient -> Done'); this.initiateHandshake(); await this.handleAsyncE2EESuggestedKey(); - logger.log('decryptSubscriptions'); + this.log('decryptSubscriptions'); await this.decryptSubscriptions(); - logger.log('decryptSubscriptions -> Done'); + this.log('decryptSubscriptions -> Done'); await this.initiateKeyDistribution(); - logger.log('initiateKeyDistribution -> Done'); + this.log('initiateKeyDistribution -> Done'); this.observeSubscriptions(); - logger.log('observing subscriptions'); + this.log('observing subscriptions'); } async onSubscriptionChanged(sub: ISubscription) { - this.info('Subscription changed', sub); + this.log('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -210,18 +180,6 @@ class E2E extends Emitter<{ await e2eRoom.decryptSubscription(); } - async acceptSuggestedKey(rid: string): Promise { - await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { - rid, - }); - } - - async rejectSuggestedKey(rid: string): Promise { - await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { - rid, - }); - } - private unsubscribeFromSubscriptions: (() => void) | undefined; observeSubscriptions() { @@ -235,7 +193,7 @@ class E2E extends Emitter<{ const excess = instatiated.difference(subscribed); if (excess.size) { - this.info('Unsubscribing from excess instances', excess); + this.log('Unsubscribing from excess instances', excess); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -246,7 +204,7 @@ class E2E extends Emitter<{ } shouldAskForE2EEPassword() { - const { private_key } = this.e2ee.getKeysFromLocalStorage(); + const { private_key } = this.getKeysFromLocalStorage(); return this.db_private_key && !private_key; } @@ -260,33 +218,50 @@ class E2E extends Emitter<{ this.emit(nextState); } - async room(rid: IRoom['_id'], fn: (room: E2ERoom) => Promise) { - const e2eRoom = await this.getInstanceByRoomId(rid); - if (e2eRoom) { - await fn(e2eRoom); - } - } - - /** - * Handles the suggested E2E key for a subscription. - */ async handleAsyncE2EESuggestedKey() { - const subs = filterSubscriptions(isRoomWithSuggestedKey); - if (!subs.length) { - return; - } + const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( - subs.map(async (sub) => { - await this.room(sub.rid, async (e2eRoom) => { - await e2eRoom.handleSuggestedKey(sub.E2ESuggestedKey); + subs + .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) + .map(async (sub) => { + const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); + + if (!e2eRoom) { + return; + } + + if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { + this.log('Imported valid E2E suggested key'); + await e2e.acceptSuggestedKey(sub.rid); + e2eRoom.keyReceived(); + } else { + this.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + await e2e.rejectSuggestedKey(sub.rid); + } + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - }); - }), + }), ); } + private waitForRoom(rid: IRoom['_id']): Promise { + return new Promise((resolve) => { + const room = Rooms.state.get(rid); + + if (room) resolve(room); + + const unsubscribe = Rooms.use.subscribe((state) => { + const room = state.get(rid); + if (room) { + unsubscribe(); + resolve(room); + } + }); + }); + } + async getInstanceByRoomId(rid: IRoom['_id']): Promise { - const room = await waitForRoom(rid); + const room = await this.waitForRoom(rid); if (room.t !== 'd' && room.t !== 'p') { return null; @@ -318,6 +293,47 @@ class E2E extends Emitter<{ delete this.instancesByRoomId[rid]; } + private async persistKeys( + { public_key, private_key }: KeyPair, + password: string, + { force }: { force: boolean } = { force: false }, + ): Promise { + if (typeof public_key !== 'string' || typeof private_key !== 'string') { + throw new Error('Failed to persist keys as they are not strings.'); + } + + const encodedPrivateKey = await this.encodePrivateKey(private_key, password); + + if (!encodedPrivateKey) { + throw new Error('Failed to encode private key with provided password.'); + } + + await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { + public_key, + private_key: encodedPrivateKey, + force, + }); + } + + async acceptSuggestedKey(rid: string): Promise { + await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { + rid, + }); + } + + async rejectSuggestedKey(rid: string): Promise { + await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { + rid, + }); + } + + getKeysFromLocalStorage(): KeyPair { + return { + public_key: Accounts.storageLocation.getItem('public_key'), + private_key: Accounts.storageLocation.getItem('private_key'), + }; + } + initiateHandshake() { Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake()); } @@ -333,7 +349,7 @@ class E2E extends Emitter<{ imperativeModal.close(); }, onConfirm: () => { - void this.e2ee.removeRandomPassword(); + Accounts.storageLocation.removeItem('e2e.randomPassword'); this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); this.closeAlert(); @@ -348,27 +364,23 @@ class E2E extends Emitter<{ return; } - logger.log('startClient -> STARTED'); + this.log('startClient -> STARTED'); this.started = true; - const localKeys = this.e2ee.getKeysFromLocalStorage(); + let { public_key, private_key } = this.getKeysFromLocalStorage(); - const dbKeys = await this.loadKeysFromDB(); - this.db_private_key = dbKeys.private_key; - this.db_public_key = dbKeys.public_key; + await this.loadKeysFromDB(); - if (!localKeys.public_key && this.db_public_key) { - localKeys.public_key = this.db_public_key; + if (!public_key && this.db_public_key) { + public_key = this.db_public_key; } - let privateKey; - if (this.shouldAskForE2EEPassword()) { try { this.setState('ENTER_PASSWORD'); - privateKey = await this.decodePrivateKey(this.db_private_key); - } catch { + private_key = await this.decodePrivateKey(this.db_private_key!); + } catch (error) { this.started = false; failedToDecodeKey = true; this.openAlert({ @@ -386,8 +398,8 @@ class E2E extends Emitter<{ } } - if (localKeys.public_key && privateKey) { - await this.loadKeys({ public_key: localKeys.public_key, private_key: privateKey }); + if (public_key && private_key) { + await this.loadKeys({ public_key, private_key }); this.setState('READY'); } else { await this.createAndLoadKeys(); @@ -396,10 +408,10 @@ class E2E extends Emitter<{ if (!this.db_public_key || !this.db_private_key) { this.setState('LOADING_KEYS'); - await this.e2ee.backupKeys(); + await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } - const randomPassword = this.e2ee.getRandomPassword(); + const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); if (randomPassword) { this.setState('SAVE_PASSWORD'); this.openAlert({ @@ -413,11 +425,12 @@ class E2E extends Emitter<{ } } - stopClient(): void { - logger.log('-> Stop Client'); + async stopClient(): Promise { + this.log('-> Stop Client'); this.closeAlert(); - this.e2ee.removeKeysFromLocalStorage(); + Accounts.storageLocation.removeItem('public_key'); + Accounts.storageLocation.removeItem('private_key'); this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; @@ -428,46 +441,73 @@ class E2E extends Emitter<{ } async changePassword(newPassword: string): Promise { - await this.e2ee.changePassphrase(newPassword); + await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true }); + + if (Accounts.storageLocation.getItem('e2e.randomPassword')) { + Accounts.storageLocation.setItem('e2e.randomPassword', newPassword); + } } - async loadKeysFromDB(): Promise { - this.setState('LOADING_KEYS'); - const result = await this.e2ee.loadKeysFromDB(); - if (!result.isOk) { + async loadKeysFromDB(): Promise { + try { + this.setState('LOADING_KEYS'); + const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys'); + + this.db_public_key = public_key; + this.db_private_key = private_key; + } catch (error) { this.setState('ERROR'); - logger.error('Error fetching RSA keys from DB: ', result.error); + this.error('Error fetching RSA keys: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation - throw result.error; + throw error; } - - return result.value; } - async loadKeys(keys: EncryptedKeyPair): Promise { - this.publicKey = keys.public_key; - const res = await this.e2ee.loadKeys(keys); - if (!res.isOk) { + async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { + Accounts.storageLocation.setItem('public_key', public_key); + this.publicKey = public_key; + + try { + this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); + + Accounts.storageLocation.setItem('private_key', private_key); + } catch (error) { this.setState('ERROR'); - logger.error('Error loading keys: ', res.error); - return; + return this.error('Error importing private key: ', error); } - this.privateKey = res.value; } async createAndLoadKeys(): Promise { // Could not obtain public-private keypair from server. this.setState('LOADING_KEYS'); - const keys = await this.e2ee.createAndLoadKeys(); + let key; + try { + key = await generateRSAKey(); + this.privateKey = key.privateKey; + } catch (error) { + this.setState('ERROR'); + return this.error('Error generating key: ', error); + } - if (!keys.isOk) { + try { + const publicKey = await exportJWKKey(key.publicKey); + + this.publicKey = JSON.stringify(publicKey); + Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); + } catch (error) { this.setState('ERROR'); - return logger.error('Error creating keys: ', keys.error); + return this.error('Error exporting public key: ', error); } - this.publicKey = JSON.stringify(keys.value.publicKey); - this.privateKey = keys.value.privateKey; + try { + const privateKey = await exportJWKKey(key.privateKey); + + Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); + } catch (error) { + this.setState('ERROR'); + return this.error('Error exporting private key: ', error); + } await this.requestSubscriptionKeys(); } @@ -476,13 +516,50 @@ class E2E extends Emitter<{ await sdk.call('e2e.requestSubscriptionKeys'); } + async createRandomPassword(): Promise { + const randomPassword = await generateMnemonicPhrase(5); + Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); + return randomPassword; + } + async encodePrivateKey(privateKey: string, password: string): Promise { - const res = await this.e2ee.encodePrivateKey(privateKey, password); - if (res.isOk) { - return res.value; + const masterKey = await this.getMasterKey(password); + + const vector = crypto.getRandomValues(new Uint8Array(12)); + try { + if (!masterKey) { + throw new Error('Error getting master key'); + } + const encodedPrivateKey = await encryptAesGcm(vector, masterKey, toArrayBuffer(privateKey)); + + return EJSON.stringify(joinVectorAndEncryptedData(vector, encodedPrivateKey)); + } catch (error) { + this.setState('ERROR'); + return this.error('Error encrypting encodedPrivateKey: ', error); + } + } + + async getMasterKey(password: string): Promise { + if (password == null) { + alert('You should provide a password'); + } + + // First, create a PBKDF2 "key" containing the password + let baseKey; + try { + baseKey = await importRawKey(toArrayBuffer(password)); + } catch (error) { + this.setState('ERROR'); + return this.error('Error creating a key based on user password: ', error); + } + + // Derive a key from the password + try { + return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); + } catch (error) { + this.setState('ERROR'); + return this.error('Error deriving baseKey: ', error); } - this.setState('ERROR'); - return logger.error('Error encoding private key: ', res.error); } openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { @@ -536,41 +613,56 @@ class E2E extends Emitter<{ async decodePrivateKeyFlow() { const password = await this.requestPasswordModal(); + const masterKey = await this.getMasterKey(password); if (!this.db_private_key) { return; } - const res = await this.e2ee.decodePrivateKey(this.db_private_key, password); + const [vector, cipherText] = splitVectorAndEncryptedData(EJSON.parse(this.db_private_key), 12); + + try { + if (!masterKey) { + throw new Error('Error getting master key'); + } + const privKey = await decryptAesGcm(vector, masterKey, cipherText); + const privateKey = toString(privKey) as string; - if (!res.isOk) { + if (this.db_public_key && privateKey) { + await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); + this.setState('READY'); + } else { + await this.createAndLoadKeys(); + this.setState('READY'); + } + dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') }); + } catch (error) { this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw res.error; + throw new Error('E2E -> Error decrypting private key', { cause: error }); } + } - const privateKey = res.value; + async decodePrivateKey(privateKey: string): Promise { + const password = await this.requestPasswordAlert(); - if (this.db_public_key && privateKey) { - await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); - } else { - await this.createAndLoadKeys(); - } + const masterKey = await this.getMasterKey(password); - this.setState('READY'); - } + const [vector, cipherText] = splitVectorAndEncryptedData(EJSON.parse(privateKey), 12); - async decodePrivateKey(privateKey: string): Promise { - const password = await this.requestPasswordAlert(); - const res = await this.e2ee.decodePrivateKey(privateKey, password); - if (res.isOk) { - return res.value; + try { + if (!masterKey) { + throw new Error('Error getting master key'); + } + const privKey = await decryptAesGcm(vector, masterKey, cipherText); + return toString(privKey); + } catch (error) { + this.setState('ENTER_PASSWORD'); + dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); + dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); + throw new Error('E2E -> Error decrypting private key', { cause: error }); } - this.setState('ENTER_PASSWORD'); - dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); - dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); - throw new Error('E2E -> Error decrypting private key'); } async decryptFileContent(file: IUploadWithUser): Promise { @@ -618,10 +710,7 @@ class E2E extends Emitter<{ return message; } - const data = await e2eRoom.decrypt({ - algorithm: 'rc.v2.aes-gcm-sha2', - ciphertext: pinnedMessage, - }); + const data = await e2eRoom.decrypt(pinnedMessage); if (!data) { return message; @@ -642,12 +731,14 @@ class E2E extends Emitter<{ async decryptSubscription(subscriptionId: ISubscription['_id']): Promise { const e2eRoom = await this.getInstanceByRoomId(subscriptionId); - logger.log('decryptSubscription ->', subscriptionId); + this.log('decryptSubscription ->', subscriptionId); await e2eRoom?.decryptSubscription(); } async decryptSubscriptions(): Promise { - filterSubscriptions(isRoomEncrypted).forEach((subscription) => this.decryptSubscription(subscription._id)); + Subscriptions.state + .filter((subscription) => Boolean(subscription.encrypted)) + .forEach((subscription) => this.decryptSubscription(subscription._id)); } openAlert(config: Omit): void { @@ -672,15 +763,15 @@ class E2E extends Emitter<{ return; } - const urlObj = new URL(url); + const urlObj = URL.parse(url); // if the URL doesn't have query params (doesn't reference message) skip - if (!urlObj.search) { + if (!urlObj.query) { return; } - const [msgId, ...rest] = urlObj.searchParams.getAll('msg'); + const { msg: msgId } = QueryString.parse(urlObj.query); - if (!msgId || rest.length > 0) { + if (!msgId || Array.isArray(msgId)) { return; } @@ -739,7 +830,7 @@ class E2E extends Emitter<{ return []; } - const randomRoomIds = sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); + const randomRoomIds = _.sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); const sampleIds: string[] = []; for await (const roomId of randomRoomIds) { @@ -795,7 +886,7 @@ class E2E extends Emitter<{ try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return logger.error('Error providing group key to users: ', error); + return this.error('Error providing group key to users: ', error); } }; @@ -808,5 +899,5 @@ class E2E extends Emitter<{ export const e2e = new E2E(); Accounts.onLogout(() => { - e2e.stopClient(); + void e2e.stopClient(); }); diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index fae3fe4b1cb88..d8b8a46543563 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -1,6 +1,7 @@ import { Box, PasswordInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, Button, Divider } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; +import { Accounts } from 'meteor/accounts-base'; import type { ComponentProps, ReactElement } from 'react'; import { useId, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -12,8 +13,8 @@ const EndToEnd = (props: ComponentProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const publicKey = localStorage.getItem('public_key'); - const privateKey = localStorage.getItem('private_key'); + const publicKey = Accounts.storageLocation.getItem('public_key'); + const privateKey = Accounts.storageLocation.getItem('private_key'); const resetE2EPassword = useResetE2EPasswordMutation(); diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index c04cfaf6eb92c..5d03f9f10aeb9 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -18,7 +18,7 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - e2e.info('Not logged in'); + e2e.log('Not logged in'); return; } @@ -28,10 +28,10 @@ export const useE2EEncryption = () => { } if (enabled && !adminEmbedded) { - e2e.info('E2E enabled starting client'); + e2e.log('E2E enabled starting client'); e2e.startClient(); } else { - e2e.info('E2E disabled'); + e2e.log('E2E disabled'); e2e.setState('DISABLED'); e2e.closeAlert(); } @@ -48,12 +48,12 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - e2e.info('Not ready'); + e2e.log('Not ready'); return; } if (listenersAttachedRef.current) { - e2e.info('Listeners already attached'); + e2e.log('Listeners already attached'); return; } @@ -120,10 +120,10 @@ export const useE2EEncryption = () => { }); listenersAttachedRef.current = true; - e2e.info('Listeners attached'); + e2e.log('Listeners attached'); return () => { - e2e.info('Not ready'); + e2e.log('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts index bf68e521fa727..eaeb20a0504eb 100644 --- a/packages/e2ee/src/index.ts +++ b/packages/e2ee/src/index.ts @@ -11,7 +11,7 @@ import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; import { /** decryptAesCbc, encryptAesCbc, **/ encryptAesGcm, decryptAesGcm } from './aes.ts'; // import { Keychain } from './keychain.ts'; -const generateMnemonicPhrase = async (length: number): Promise => { +export const generateMnemonicPhrase = async (length: number): Promise => { const { wordlist } = await import('./wordlists/v2.ts'); const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); return Array.from(randomBuffer, (value) => wordlist[value % wordlist.length]).join(' '); From c2d9ad1795a8141f56dc769a348ec99119ea6cec Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 10 Sep 2025 08:36:54 -0300 Subject: [PATCH 143/251] log error on decoding private key --- .../client/hooks/notification/useDesktopNotification.ts | 5 +---- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index 0ab742bf203cf..6d7a24317e4d0 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -25,10 +25,7 @@ export const useDesktopNotification = () => { if (notification.payload.message?.t === 'e2e') { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { - notification.text = (await e2eRoom.decrypt({ - ciphertext: notification.payload.message.msg, - algorithm: 'rc.v2.aes-gcm-sha2' - })).text; + notification.text = (await e2eRoom.decrypt(notification.payload.message.msg)).text; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 5a6d94d159ba2..8c7b484882bd7 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -394,7 +394,7 @@ class E2E extends Emitter { this.closeAlert(); }, }); - return; + return this.error('E2E -> Error decoding private key: ', error); } } From 45671495ae3e618bf1096d58fdd4653b26230904 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 10 Sep 2025 12:57:54 -0300 Subject: [PATCH 144/251] improve logging --- apps/meteor/client/lib/e2ee/helper.ts | 26 ++-- apps/meteor/client/lib/e2ee/logger.ts | 114 ++++++++++++++++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 47 +++++--- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 8 +- .../CustomSoundProvider.tsx | 2 +- apps/meteor/client/views/root/AppLayout.tsx | 4 +- 6 files changed, 161 insertions(+), 40 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index bf6bf114fabd9..8f29794854aec 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -62,18 +62,20 @@ export async function decryptAesCbc(vector: Uint8Array, key: Crypto export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, data: BufferSource) { if (iv.length !== 12) { - throw new Error('Invalid iv length for AES-GCM. Must be 12 bytes.'); + throw new Error(`Invalid iv length for AES-GCM: ${iv.length}. Must be 12 bytes.`); } - return crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); + return encrypted; } export async function decryptAesGcm(iv: Uint8Array, key: CryptoKey, data: Uint8Array) { if (iv.length !== 12) { - throw new Error('Invalid iv length for AES-GCM. Must be 12 bytes.'); + throw new Error(`Invalid iv length for AES-GCM: ${iv.length}. Must be 12 bytes.`); } - return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); + return decrypted; } export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { @@ -84,19 +86,23 @@ export async function decryptRSA(key: CryptoKey, data: Uint8Array) return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } +/** + * Generates an AES-CBC key. + * @deprecated Use {@link generateAesGcmKey} instead. + */ export async function generateAesCbcKey() { return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); } -export async function generateAesGcmKey() { +export function generateAesGcmKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -export async function generateAESCTRKey() { +export function generateAESCTRKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } -export async function generateRSAKey() { +export function generateRSAKey(): Promise { return crypto.subtle.generateKey( { name: 'RSA-OAEP', @@ -109,11 +115,11 @@ export async function generateRSAKey() { ); } -export async function exportJWKKey(key: CryptoKey): Promise { +export function exportJWKKey(key: CryptoKey): Promise { return crypto.subtle.exportKey('jwk', key); } -export async function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { +export function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { return crypto.subtle.importKey( 'jwk', keyData, @@ -132,7 +138,7 @@ export async function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray * Imports an AES-CBC key from JWK format. * @deprecated Use {@link importAesGcmKey} instead. */ -export async function importAesCbcKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { +export function importAesCbcKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages); } diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 65f4786d61400..3eb78bd1d93e0 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -2,14 +2,114 @@ const noop = (): void => { // do nothing }; +class Level { -export const createLogger = (level: 0 | 1 | 2 | 3 | 4 | 5 = 0) => { + static TRACE = new Level(0); + + static DEBUG = new Level(1); + + static INFO = new Level(2); + + static WARN = new Level(3); + + static ERROR = new Level(4); + + static fromString(label: string): Level | undefined { + switch (label.toUpperCase()) { + case 'TRACE': + return Level.TRACE; + case 'DEBUG': + return Level.DEBUG; + case 'INFO': + return Level.INFO; + case 'WARN': + return Level.WARN; + case 'ERROR': + return Level.ERROR; + default: + return undefined; + } + } + + readonly value: T; + + private constructor(value: `${T}` extends `${string}.${string}` ? never : T) { + this.value = value; + } + + get [Symbol.toStringTag](): string { + return `Level(${this.value})`; + } + + toString(): string { + switch (this.value) { + case Level.TRACE.value: + return 'TRACE'; + case Level.DEBUG.value: + return 'DEBUG'; + case Level.INFO.value: + return 'INFO'; + case Level.WARN.value: + return 'WARN'; + case Level.ERROR.value: + return 'ERROR'; + default: + return `LEVEL(${this.value})`; + } + } + + compareTo(other: Level): number { + return this.value - other.value; + } +} + +class LevelFilter { + static OFF = new LevelFilter(undefined); + + static ERROR = LevelFilter.fromLevel(Level.ERROR); + + static WARN = LevelFilter.fromLevel(Level.WARN); + + static INFO = LevelFilter.fromLevel(Level.INFO); + + static DEBUG = LevelFilter.fromLevel(Level.DEBUG); + + static TRACE = LevelFilter.fromLevel(Level.TRACE); + + static fromLevel(level: Level): LevelFilter { + return new LevelFilter(level); + } + + readonly level: Level | undefined; + + private constructor(level: Level | undefined) { + this.level = level ?? Level.INFO; + } + + shouldLog(level: Level): boolean { + if (!this.level) { + return false; + } + return level.compareTo(this.level) >= 0; + } +} + +const getLevelFilter = (): Level => { + const searchParams = new URLSearchParams(window.location.search); + const logLevel = searchParams.get('logLevel') ?? 'INFO'; + const level = Level.fromString(logLevel) ?? Level.INFO; + return level; +} + + +export const createLogger = (level: Level = getLevelFilter()) => { + const filter = LevelFilter.fromLevel(level); return { - log: level <= 0 ? console.log.bind(console) : noop, - info: level <= 1 ? console.info.bind(console) : noop, - warn: level <= 2 ? console.warn.bind(console) : noop, - error: level <= 3 ? console.error.bind(console) : noop, - debug: level <= 4 ? console.debug.bind(console) : noop, - trace: level <= 5 ? console.trace.bind(console) : noop, + log: filter.shouldLog(Level.INFO) ? console.log : noop, + info: filter.shouldLog(Level.INFO) ? console.info : noop, + warn: filter.shouldLog(Level.WARN) ? console.warn : noop, + error: filter.shouldLog(Level.ERROR) ? console.error : noop, + debug: filter.shouldLog(Level.DEBUG) ? console.debug : noop, + trace: filter.shouldLog(Level.TRACE) ? console.trace : noop, }; } \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index e469e4807832d..7d07dbf8b5e28 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -33,7 +33,7 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; -const { info: log, error: logError } = createLogger(); +const logger = createLogger(); const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -112,11 +112,6 @@ export class E2ERoom extends Emitter { return this.decryptPendingMessages(); }); this.once('READY', () => this.decryptSubscription()); - this.on('STATE_CHANGED', (prev) => { - if (this.roomId === RoomManager.opened) { - this.log(`[PREV: ${prev}]`, 'State CHANGED'); - } - }); this.on('STATE_CHANGED', () => this.handshake()); this.setState('NOT_STARTED'); @@ -124,13 +119,25 @@ export class E2ERoom extends Emitter { log(...msg: unknown[]) { if (this.roomId === RoomManager.opened) { - log(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + logger.info(`[E2E] ROOM`, ...msg); + } else { + logger.debug(`[E2E] ROOM ${this.roomId}`, ...msg); } } error(...msg: unknown[]) { if (this.roomId === RoomManager.opened) { - logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); + logger.error(`[E2E] ROOM`, ...msg); + } else { + logger.trace(`[E2E] ROOM ${this.roomId}`, ...msg); + } + } + + warn(...msg: unknown[]) { + if (this.roomId === RoomManager.opened) { + logger.warn(`[E2E] ROOM`, ...msg); + } else { + logger.trace(`[E2E] ROOM ${this.roomId}`, ...msg); } } @@ -178,13 +185,13 @@ export class E2ERoom extends Emitter { } pause() { - this.log('PAUSED', this[PAUSED], '->', true); + // this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - this.log('PAUSED', this[PAUSED], '->', false); + // this.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } @@ -377,7 +384,7 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string) { - this.log('Importing room key ->', this.roomId); + this.log('Importing room key'); // Get existing group key const keyID = groupKey.slice(0, 36); groupKey = groupKey.slice(36); @@ -623,7 +630,9 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } const result = await encryptAesGcm(vector, this.groupSessionKey, data); - return this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); + const ciphertext = this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); + this.log('Message encrypted successfully', { ciphertext }); + return ciphertext; } catch (error) { this.error('Error encrypting message: ', error); throw error; @@ -726,7 +735,7 @@ export class E2ERoom extends Emitter { let oldKey = null; if (keyID !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key: any) => key.e2eKeyId === keyID); + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === keyID); // Messages already contain a keyID stored with them // That means that if we cannot find a keyID for the key the message has preppended to // The message is indecipherable. @@ -737,7 +746,9 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); + const result = await this.doDecrypt(vector, this.groupSessionKey, cipherText); + this.warn('Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', { message: result }); + return result; } catch (error) { this.error('Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; @@ -748,12 +759,16 @@ export class E2ERoom extends Emitter { try { if (oldKey) { - return await this.doDecrypt(vector, oldKey, cipherText); + const result = await this.doDecrypt(vector, oldKey, cipherText); + this.log('Message decrypted', { result, keyID }); + return result; } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - return await this.doDecrypt(vector, this.groupSessionKey, cipherText); + const result = await this.doDecrypt(vector, this.groupSessionKey, cipherText); + this.log('Message decrypted', { result, keyID }); + return result; } catch (error) { this.error('Error decrypting message: ', error, message); return { msg: t('E2E_Key_Error') }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 8c7b484882bd7..934dfffb2ed8e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -45,7 +45,7 @@ import { mapMessageFromApi } from '../utils/mapMessageFromApi'; let failedToDecodeKey = false; -const { info: log, error: logError } = createLogger(); +const logger = createLogger(); type KeyPair = { public_key: string | null; @@ -105,11 +105,11 @@ class E2E extends Emitter { } log(...msg: unknown[]) { - log('E2E', ...msg); + logger.info('[E2E]', ...msg); } error(...msg: unknown[]) { - logError('E2E', ...msg); + logger.error('[E2E]', ...msg); } getState() { @@ -139,7 +139,7 @@ class E2E extends Emitter { } async onSubscriptionChanged(sub: ISubscription) { - this.log('Subscription changed', sub); + // this.log('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index e06dd7a354a19..b8ddb56bda7ee 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -47,7 +47,7 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { audio.volume = volume; audio.loop = loop; audio.id = soundId; - audio.play(); + audio.play().catch(); audioRefs.current = [...audioRefs.current, audio]; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index 327dc95d46bab..e46fa12b2ca82 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -22,7 +22,7 @@ import { useCorsSSLConfig } from '../../../app/cors/client/useCorsSSLConfig'; import { useEmojiOne } from '../../../app/emoji-emojione/client/hooks/useEmojiOne'; import { useLivechatEnterprise } from '../../../app/livechat-enterprise/hooks/useLivechatEnterprise'; import { useIframeLoginListener } from '../../hooks/iframe/useIframeLoginListener'; -import { useNotificationPermission } from '../../hooks/notification/useNotificationPermission'; +// import { useNotificationPermission } from '../../hooks/notification/useNotificationPermission'; import { useAnalytics } from '../../hooks/useAnalytics'; import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking'; import { useAutoupdate } from '../../hooks/useAutoupdate'; @@ -46,7 +46,7 @@ const AppLayout = () => { useEscapeKeyStroke(); useAnalyticsEventTracking(); useLoadRoomForAllowedAnonymousRead(); - useNotificationPermission(); + // useNotificationPermission(); useEmojiOne(); // useRedirectToSetupWizard(); useSettingsOnLoadSiteUrl(); From 4509612ec834a723e8bbfa619af3cd5703d5e3ca Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 10 Sep 2025 16:04:40 -0300 Subject: [PATCH 145/251] parse payload --- apps/meteor/client/lib/e2ee/helper.ts | 10 +-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 70 ++++++++++++++----- packages/e2ee/docs/NOTES.md | 1 + 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 8f29794854aec..911726823e5cb 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -33,11 +33,11 @@ export function joinVectorAndEncryptedData(vector: Uint8Array, encr return output; } -export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16): [Uint8Array, Uint8Array] { +export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16 = 16): [ArrayBuffer, ArrayBuffer] { const vector = cipherText.slice(0, ivLength); const encryptedData = cipherText.slice(ivLength); - return [vector, encryptedData]; + return [vector.buffer, encryptedData.buffer]; } export async function encryptRSA(key: CryptoKey, data: BufferSource) { @@ -69,11 +69,7 @@ export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, return encrypted; } -export async function decryptAesGcm(iv: Uint8Array, key: CryptoKey, data: Uint8Array) { - if (iv.length !== 12) { - throw new Error(`Invalid iv length for AES-GCM: ${iv.length}. Must be 12 bytes.`); - } - +export async function decryptAesGcm(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); return decrypted; } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 7d07dbf8b5e28..9fd60f53f00ea 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { Base64 } from '@rocket.chat/base64'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; @@ -8,7 +9,7 @@ import type { E2ERoomState } from './E2ERoomState'; import { toString, toArrayBuffer, - joinVectorAndEncryptedData, + // joinVectorAndEncryptedData, splitVectorAndEncryptedData, encryptRSA, encryptAesGcm, @@ -45,14 +46,7 @@ const permitedMutations: Mutations = { READY: ['DISABLED', 'CREATING_KEYS', 'WAITING_KEYS'], ERROR: ['KEYS_RECEIVED', 'NOT_STARTED'], WAITING_KEYS: ['KEYS_RECEIVED', 'ERROR', 'DISABLED'], - ESTABLISHING: [ - 'READY', - 'KEYS_RECEIVED', - 'ERROR', - 'DISABLED', - 'WAITING_KEYS', - 'CREATING_KEYS', - ], + ESTABLISHING: ['READY', 'KEYS_RECEIVED', 'ERROR', 'DISABLED', 'WAITING_KEYS', 'CREATING_KEYS'], }; const filterMutation = (currentState: E2ERoomState | undefined, nextState: E2ERoomState): E2ERoomState | false => { @@ -395,8 +389,6 @@ export class E2ERoom extends Emitter { return true; } - - // Decrypt obtained encrypted session key try { if (!e2e.privateKey) { @@ -405,7 +397,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - this.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey, }); + this.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); return false; } @@ -630,7 +622,11 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } const result = await encryptAesGcm(vector, this.groupSessionKey, data); - const ciphertext = this.keyID + Base64.encode(joinVectorAndEncryptedData(vector, result)); + const ciphertext = EJSON.stringify({ + key_id: this.keyID, + iv: vector, + ciphertext: new Uint8Array(result), + }); this.log('Message encrypted successfully', { ciphertext }); return ciphertext; } catch (error) { @@ -722,16 +718,49 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: Uint8Array, key: CryptoKey, cipherText: Uint8Array) { + async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer) { const result = await decryptAesGcm(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } - async decrypt(message: string) { - const keyID = message.slice(0, 36); - message = message.slice(36); + // Parses the message to extract the keyID and cipherText + parse(payload: string): { key_id: string; iv: ArrayBuffer; ciphertext: ArrayBuffer } { + // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} + if (payload[0] === '{') { + const parsed: unknown = EJSON.parse(payload); + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Parsed payload is not an object'); + } + if (!('key_id' in parsed && 'iv' in parsed && 'ciphertext' in parsed)) { + throw new Error('Parsed payload is missing required fields'); + } + + const { key_id, iv, ciphertext } = parsed; + + if (typeof key_id !== 'string' || !(iv instanceof Uint8Array) || !(ciphertext instanceof Uint8Array)) { + throw new Error('Parsed payload has invalid field types'); + } + + if (!(iv.buffer instanceof ArrayBuffer) || !(ciphertext.buffer instanceof ArrayBuffer)) { + throw new Error('Parsed payload has invalid field types'); + } - const [vector, cipherText] = splitVectorAndEncryptedData(Base64.decode(message), 12); + return { key_id, iv: iv.buffer, ciphertext: ciphertext.buffer }; + } + // v1: keyID + base64(vector + ciphertext) + const message = payload; + const key_id = message.slice(0, 12); + const [iv, ciphertext] = splitVectorAndEncryptedData(Base64.decode(message.slice(12))); + return { + key_id, + iv, + ciphertext, + }; + } + + async decrypt(message: string) { + const { key_id: keyID, iv: vector, ciphertext: cipherText } = this.parse(message); let oldKey = null; if (keyID !== this.keyID) { @@ -747,7 +776,10 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } const result = await this.doDecrypt(vector, this.groupSessionKey, cipherText); - this.warn('Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', { message: result }); + this.warn( + 'Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', + { message: result }, + ); return result; } catch (error) { this.error('Error decrypting message: ', error, message); diff --git a/packages/e2ee/docs/NOTES.md b/packages/e2ee/docs/NOTES.md index b4e688c5060f8..34ca174a45149 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/packages/e2ee/docs/NOTES.md @@ -31,6 +31,7 @@ - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) - [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) +- [chore: Upgrade `@tanstack/query` to v5](https://github.com/RocketChat/Rocket.Chat/pull/33898) - [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) - [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) From 6dcca491464163847653e99a5a1ee1734b33fbf0 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 10 Sep 2025 18:30:54 -0300 Subject: [PATCH 146/251] e2ee v2 format --- apps/meteor/client/lib/e2ee/helper.ts | 17 ++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 86 +++++++++---------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 +- .../core-typings/src/IMessage/IMessage.ts | 7 +- 4 files changed, 60 insertions(+), 54 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 911726823e5cb..912855e68df33 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -56,15 +56,11 @@ export async function encryptAesCbc(vector: Uint8Array, key: Crypto return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); } -export async function decryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { - return crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); +export async function decryptAesCbc(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { + return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data); } -export async function encryptAesGcm(iv: Uint8Array, key: CryptoKey, data: BufferSource) { - if (iv.length !== 12) { - throw new Error(`Invalid iv length for AES-GCM: ${iv.length}. Must be 12 bytes.`); - } - +export async function encryptAesGcm(iv: ArrayBuffer, key: CryptoKey, data: BufferSource) { const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); return encrypted; } @@ -74,6 +70,13 @@ export async function decryptAesGcm(iv: ArrayBuffer, key: CryptoKey, data: Array return decrypted; } +export function decryptAes(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { + if (key.algorithm.name === 'AES-GCM') { + return decryptAesGcm(iv, key, data); + } + return decryptAesCbc(iv, key, data); +} + export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 9fd60f53f00ea..84b959742a70f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -14,7 +14,7 @@ import { encryptRSA, encryptAesGcm, decryptRSA, - decryptAesGcm, + // decryptAesGcm, generateAesGcmKey, exportJWKKey, importAesGcmKey, @@ -24,6 +24,7 @@ import { generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, + decryptAes, } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -621,12 +622,12 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await encryptAesGcm(vector, this.groupSessionKey, data); - const ciphertext = EJSON.stringify({ + const result = await encryptAesGcm(vector.buffer, this.groupSessionKey, data); + const ciphertext = { key_id: this.keyID, - iv: vector, - ciphertext: new Uint8Array(result), - }); + iv: Base64.encode(vector), + ciphertext: Base64.encode(new Uint8Array(result)), + }; this.log('Message encrypted successfully', { ciphertext }); return ciphertext; } catch (error) { @@ -642,9 +643,9 @@ export class E2ERoom extends Emitter { const data = new TextEncoder().encode(EJSON.stringify(contentToBeEncrypted)); return { - algorithm: 'rc.v1.aes-sha2', - ciphertext: await this.encryptText(data), - }; + algorithm: 'rc.v2.aes-sha2', + ...await this.encryptText(data), + } as const; } // Helper function for encryption of content @@ -688,9 +689,20 @@ export class E2ERoom extends Emitter { } async decryptContent(data: T) { - if (data.content && data.content.algorithm === 'rc.v1.aes-sha2') { - const content = await this.decrypt(data.content.ciphertext); - Object.assign(data, content); + if (!data.content) { + return data; + } + + const { content } = data; + + if (content && content.algorithm === 'rc.v1.aes-sha2') { + const decrypted = await this.decrypt(content.ciphertext); + Object.assign(data, decrypted); + } + + if (content && content.algorithm === 'rc.v2.aes-sha2' && 'iv' in content) { + const decrypted = await this.decrypt(content); + Object.assign(data, decrypted); } return data; @@ -719,36 +731,20 @@ export class E2ERoom extends Emitter { } async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer) { - const result = await decryptAesGcm(vector, key, cipherText); + const result = await decryptAes(vector, key, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } // Parses the message to extract the keyID and cipherText - parse(payload: string): { key_id: string; iv: ArrayBuffer; ciphertext: ArrayBuffer } { + parse(payload: string | { key_id: string; iv: string; ciphertext: string }): { key_id: string; iv: ArrayBuffer; ciphertext: ArrayBuffer } { // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} - if (payload[0] === '{') { - const parsed: unknown = EJSON.parse(payload); - - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('Parsed payload is not an object'); - } - if (!('key_id' in parsed && 'iv' in parsed && 'ciphertext' in parsed)) { - throw new Error('Parsed payload is missing required fields'); - } - - const { key_id, iv, ciphertext } = parsed; - - if (typeof key_id !== 'string' || !(iv instanceof Uint8Array) || !(ciphertext instanceof Uint8Array)) { - throw new Error('Parsed payload has invalid field types'); - } - - if (!(iv.buffer instanceof ArrayBuffer) || !(ciphertext.buffer instanceof ArrayBuffer)) { - throw new Error('Parsed payload has invalid field types'); - } + if (typeof payload !== 'string') { + const iv = Base64.decode(payload.iv).buffer; + const ciphertext = Base64.decode(payload.ciphertext).buffer; - return { key_id, iv: iv.buffer, ciphertext: ciphertext.buffer }; + return { key_id: payload.key_id, iv, ciphertext }; } - // v1: keyID + base64(vector + ciphertext) + // v1: key_id + base64(vector + ciphertext) const message = payload; const key_id = message.slice(0, 12); const [iv, ciphertext] = splitVectorAndEncryptedData(Base64.decode(message.slice(12))); @@ -759,12 +755,14 @@ export class E2ERoom extends Emitter { }; } - async decrypt(message: string) { - const { key_id: keyID, iv: vector, ciphertext: cipherText } = this.parse(message); + async decrypt(message: string | { key_id: string; iv: string; ciphertext: string }) { + const payload = this.parse(message); + + this.log('Decrypting payload', payload); let oldKey = null; - if (keyID !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === keyID); + if (payload.key_id !== this.keyID) { + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === payload.key_id); // Messages already contain a keyID stored with them // That means that if we cannot find a keyID for the key the message has preppended to // The message is indecipherable. @@ -775,7 +773,7 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await this.doDecrypt(vector, this.groupSessionKey, cipherText); + const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); this.warn( 'Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', { message: result }, @@ -791,15 +789,15 @@ export class E2ERoom extends Emitter { try { if (oldKey) { - const result = await this.doDecrypt(vector, oldKey, cipherText); - this.log('Message decrypted', { result, keyID }); + const result = await this.doDecrypt(payload.iv, oldKey, payload.ciphertext); + this.log('Message decrypted', { result }); return result; } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await this.doDecrypt(vector, this.groupSessionKey, cipherText); - this.log('Message decrypted', { result, keyID }); + const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); + this.log('Message decrypted', result); return result; } catch (error) { this.error('Error decrypting message: ', error, message); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 934dfffb2ed8e..6f3aff5536164 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -139,7 +139,7 @@ class E2E extends Emitter { } async onSubscriptionChanged(sub: ISubscription) { - // this.log('Subscription changed', sub); + this.log('Subscription changed', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -530,7 +530,7 @@ class E2E extends Emitter { if (!masterKey) { throw new Error('Error getting master key'); } - const encodedPrivateKey = await encryptAesGcm(vector, masterKey, toArrayBuffer(privateKey)); + const encodedPrivateKey = await encryptAesGcm(vector.buffer, masterKey, toArrayBuffer(privateKey)); return EJSON.stringify(joinVectorAndEncryptedData(vector, encodedPrivateKey)); } catch (error) { diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 5ed03ce466d05..e0ab71164b60f 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -232,8 +232,13 @@ export interface IMessage extends IRocketChatRecord { customFields?: IMessageCustomFields; content?: { - algorithm: string; // 'rc.v1.aes-sha2' + algorithm: 'rc.v1.aes-sha2', ciphertext: string; // Encrypted subset JSON of IMessage + } | { + algorithm: 'rc.v2.aes-sha2', + iv: string; // base64-encoded initialization vector + ciphertext: string; // base64-encoded encrypted subset JSON of IMessage + key_id: string; // ID of the key used to encrypt the message }; } From 7d857dcd43ef864b3fd539a0cf9fd3324128dd17 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 11 Sep 2025 12:26:52 -0300 Subject: [PATCH 147/251] fix lastMessage decryption --- .../notification/useDesktopNotification.ts | 2 +- apps/meteor/client/lib/e2ee/logger.ts | 124 ++------------- .../client/lib/e2ee/rocketchat.e2e.room.ts | 146 ++++++++---------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 53 +++---- .../root/hooks/loggedIn/useE2EEncryption.ts | 8 - apps/meteor/tests/e2e/e2e-encryption.spec.ts | 3 +- 6 files changed, 103 insertions(+), 233 deletions(-) diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index 6d7a24317e4d0..f2cced341a3de 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -25,7 +25,7 @@ export const useDesktopNotification = () => { if (notification.payload.message?.t === 'e2e') { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { - notification.text = (await e2eRoom.decrypt(notification.payload.message.msg)).text; + notification = await e2eRoom.decryptDesktopNotification(notification); } } diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 3eb78bd1d93e0..39d4aba0e78e7 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,115 +1,15 @@ -const noop = (): void => { - // do nothing -}; - -class Level { - - static TRACE = new Level(0); - - static DEBUG = new Level(1); - - static INFO = new Level(2); - - static WARN = new Level(3); - - static ERROR = new Level(4); - - static fromString(label: string): Level | undefined { - switch (label.toUpperCase()) { - case 'TRACE': - return Level.TRACE; - case 'DEBUG': - return Level.DEBUG; - case 'INFO': - return Level.INFO; - case 'WARN': - return Level.WARN; - case 'ERROR': - return Level.ERROR; - default: - return undefined; - } - } - - readonly value: T; - - private constructor(value: `${T}` extends `${string}.${string}` ? never : T) { - this.value = value; - } - - get [Symbol.toStringTag](): string { - return `Level(${this.value})`; - } - - toString(): string { - switch (this.value) { - case Level.TRACE.value: - return 'TRACE'; - case Level.DEBUG.value: - return 'DEBUG'; - case Level.INFO.value: - return 'INFO'; - case Level.WARN.value: - return 'WARN'; - case Level.ERROR.value: - return 'ERROR'; - default: - return `LEVEL(${this.value})`; - } - } - - compareTo(other: Level): number { - return this.value - other.value; - } -} - -class LevelFilter { - static OFF = new LevelFilter(undefined); - - static ERROR = LevelFilter.fromLevel(Level.ERROR); - - static WARN = LevelFilter.fromLevel(Level.WARN); - - static INFO = LevelFilter.fromLevel(Level.INFO); - - static DEBUG = LevelFilter.fromLevel(Level.DEBUG); - - static TRACE = LevelFilter.fromLevel(Level.TRACE); - - static fromLevel(level: Level): LevelFilter { - return new LevelFilter(level); - } - - readonly level: Level | undefined; - - private constructor(level: Level | undefined) { - this.level = level ?? Level.INFO; - } - - shouldLog(level: Level): boolean { - if (!this.level) { - return false; - } - return level.compareTo(this.level) >= 0; - } -} - -const getLevelFilter = (): Level => { - const searchParams = new URLSearchParams(window.location.search); - const logLevel = searchParams.get('logLevel') ?? 'INFO'; - const level = Level.fromString(logLevel) ?? Level.INFO; - return level; -} +type LogLevel = 'info' | 'warn' | 'error'; +const styles: Record = { + info: 'color: green; background-color: white; font-weight: bold;', + warn: 'color: orange; background-color: white; font-weight: bold;', + error: 'color: red; background-color: white; font-weight: bold;', +}; -export const createLogger = (level: Level = getLevelFilter()) => { - const filter = LevelFilter.fromLevel(level); - return { - log: filter.shouldLog(Level.INFO) ? console.log : noop, - info: filter.shouldLog(Level.INFO) ? console.info : noop, - warn: filter.shouldLog(Level.WARN) ? console.warn : noop, - error: filter.shouldLog(Level.ERROR) ? console.error : noop, - debug: filter.shouldLog(Level.DEBUG) ? console.debug : noop, - trace: filter.shouldLog(Level.TRACE) ? console.trace : noop, +export const createLogger = (label: string) => { + return (level: LogLevel, ...[first, ...msg]: [string, ...unknown[]]) => { + console.groupCollapsed(`%c[${level}][${label}]`, styles[level], first); + console.trace(...msg); + console.groupEnd(); }; -} \ No newline at end of file +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 84b959742a70f..a55db45dabd12 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Base64 } from '@rocket.chat/base64'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast, INotificationDesktop } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; @@ -32,10 +32,10 @@ import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { Messages, Rooms, Subscriptions } from '../../stores'; -import { RoomManager } from '../RoomManager'; +// import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; -const logger = createLogger(); +const log = createLogger('E2ERoom'); const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -112,30 +112,6 @@ export class E2ERoom extends Emitter { this.setState('NOT_STARTED'); } - log(...msg: unknown[]) { - if (this.roomId === RoomManager.opened) { - logger.info(`[E2E] ROOM`, ...msg); - } else { - logger.debug(`[E2E] ROOM ${this.roomId}`, ...msg); - } - } - - error(...msg: unknown[]) { - if (this.roomId === RoomManager.opened) { - logger.error(`[E2E] ROOM`, ...msg); - } else { - logger.trace(`[E2E] ROOM ${this.roomId}`, ...msg); - } - } - - warn(...msg: unknown[]) { - if (this.roomId === RoomManager.opened) { - logger.warn(`[E2E] ROOM`, ...msg); - } else { - logger.trace(`[E2E] ROOM ${this.roomId}`, ...msg); - } - } - hasSessionKey() { return !!this.groupSessionKey; } @@ -149,12 +125,12 @@ export class E2ERoom extends Emitter { const nextState = filterMutation(currentState, requestedState); if (!nextState) { - this.error(`invalid state ${currentState} -> ${requestedState}`); + log('error', 'setState', { currentState, requestedState }); return; } this.state = nextState; - this.log(currentState, '->', nextState); + log('info', 'setState', { currentState, nextState }); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -180,13 +156,11 @@ export class E2ERoom extends Emitter { } pause() { - // this.log('PAUSED', this[PAUSED], '->', true); this[PAUSED] = true; this.emit('PAUSED', true); } resume() { - // this.log('PAUSED', this[PAUSED], '->', false); this[PAUSED] = false; this.emit('PAUSED', false); } @@ -229,32 +203,41 @@ export class E2ERoom extends Emitter { this[KEY_ID] = keyID; } + async decryptDesktopNotification(notification: INotificationDesktop): Promise { + if (!this.isReady()) { + return notification; + } + + const message = await this.decrypt(notification.payload.message.msg); + notification.text = typeof message.msg === 'undefined' ? message.text : message.msg; + + return notification; + } + async decryptSubscription() { - const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); + const subscription = Subscriptions.state.find(({ rid, lastMessage }) => rid === this.roomId && !!lastMessage && lastMessage.t === 'e2e' && lastMessage.e2e === 'pending'); if (subscription?.lastMessage?.t !== 'e2e') { - this.log('decryptSubscriptions nothing to do'); + log('info', 'decryptSubscriptions nothing to do'); return; } const message = await this.decryptMessage(subscription.lastMessage); if (message !== subscription.lastMessage) { - this.log('decryptSubscriptions updating lastMessage'); + log('info', 'decryptSubscriptions', 'Updating last message', { before: subscription.lastMessage, after: message }); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } - - this.log('decryptSubscriptions Done'); } async decryptOldRoomKeys() { const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - this.log('decryptOldRoomKeys nothing to do'); + log('info', 'decryptOldRoomKeys', 'Nothing to do'); return; } @@ -267,21 +250,22 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - this.error( + log( + 'error', + 'decryptOldRoomKeys', `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + e, ); keys.push({ ...key, E2EKey: null }); } } this.oldKeys = keys; - this.log('decryptOldRoomKeys Done'); } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - this.log('exportOldRoomKeys starting'); if (!oldKeys || oldKeys.length === 0) { - this.log('exportOldRoomKeys nothing to do'); + log('info', 'exportOldRoomKeys', 'Nothing to do'); return; } @@ -298,13 +282,15 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - this.error( + log( + 'error', `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + e, ); } } - this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + log('info', 'exportOldRoomKeys', `Done: ${keys.length} keys exported`); return keys; } @@ -336,7 +322,7 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - this.error('Error fetching group key: ', error); + log('error', 'Error fetching group key: ', error); return; } @@ -350,10 +336,10 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - this.log('Requesting room key'); + log('info', 'Requesting room key', room.e2eKeyId); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - this.error('Error during handshake: ', error); + log('error', 'Error during handshake: ', error); this.setState('ERROR'); } } @@ -379,14 +365,13 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string) { - this.log('Importing room key'); // Get existing group key const keyID = groupKey.slice(0, 36); groupKey = groupKey.slice(36); const decodedGroupKey = Base64.decode(groupKey); if (this.keyID === keyID && this.groupSessionKey) { - this.log('KeyID matches and session key already exists. Nothing to do'); + log('info', 'importGroupKey', 'Key already imported'); return true; } @@ -398,7 +383,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - this.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); + log('error', 'Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); return false; } @@ -414,7 +399,7 @@ export class E2ERoom extends Emitter { // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - this.error('Error importing group key: ', error); + log('error', 'Error importing group key: ', error); return false; } @@ -430,7 +415,6 @@ export class E2ERoom extends Emitter { } async createGroupKey() { - this.log('Creating room key'); try { await this.createNewGroupKey(); @@ -445,15 +429,15 @@ export class E2ERoom extends Emitter { await this.encryptKeyForOtherParticipants(); } } catch (error) { - this.error('Error exporting group key: ', error); + log('error', 'Error exporting group key: ', error); throw error; } } async resetRoomKey() { - this.log('Resetting room key'); + log('info', 'Resetting room key', { currentKeyID: this.keyID }); if (!e2e.publicKey) { - this.error('Cannot reset room key. No public key found.'); + log('error', 'Cannot reset room key. No public key found.', { e2e }); return; } @@ -464,17 +448,17 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - this.log(`Room key reset done for room ${this.roomId}`); + log('info', 'Room key reset successfully', e2eNewKeys); return e2eNewKeys; } catch (error) { - this.error('Error resetting group key: ', error); + log('error', 'Error resetting group key: ', error); throw error; } } onRoomKeyReset(keyID: string) { - this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + log('info', 'Room keyID was reset', { NewKeyID: keyID, PreviousKeyID: this.keyID }); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -519,7 +503,7 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return this.error('Error getting room users: ', error); + return log('error', 'Error getting room users: ', error); } } @@ -533,7 +517,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return this.error('Error importing user key: ', error); + return log('error', 'Error importing user key: ', error); } try { @@ -549,7 +533,7 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return this.error('Error encrypting user key: ', error); + return log('error', 'Error encrypting user key: ', error); } } @@ -558,7 +542,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return this.error('Error importing user key: ', error); + return log('error', 'Error importing user key: ', error); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -568,7 +552,7 @@ export class E2ERoom extends Emitter { const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; } catch (error) { - return this.error('Error encrypting user key: ', error); + return log('error', 'Error encrypting user key: ', error); } } @@ -588,7 +572,7 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - return this.error('Error encrypting group key: ', error); + return log('error', 'Error encrypting group key: ', error); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); @@ -628,10 +612,10 @@ export class E2ERoom extends Emitter { iv: Base64.encode(vector), ciphertext: Base64.encode(new Uint8Array(result)), }; - this.log('Message encrypted successfully', { ciphertext }); + log('info', 'encryptText', ciphertext); return ciphertext; } catch (error) { - this.error('Error encrypting message: ', error); + log('error', 'Error encrypting message: ', error); throw error; } } @@ -644,7 +628,7 @@ export class E2ERoom extends Emitter { return { algorithm: 'rc.v2.aes-sha2', - ...await this.encryptText(data), + ...(await this.encryptText(data)), } as const; } @@ -710,6 +694,7 @@ export class E2ERoom extends Emitter { // Decrypt messages async decryptMessage(message: IMessage | IE2EEMessage): Promise { + log('info', 'decryptMessage', message); if (message.t !== 'e2e' || message.e2e === 'done') { return message; } @@ -717,7 +702,7 @@ export class E2ERoom extends Emitter { if (message.msg) { const data = await this.decrypt(message.msg); - if (data?.text) { + if (typeof data.msg === 'undefined') { message.msg = data.text; } } @@ -730,13 +715,18 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer) { + async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date }> { const result = await decryptAes(vector, key, cipherText); - return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); + const parsed = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); + return parsed; } // Parses the message to extract the keyID and cipherText - parse(payload: string | { key_id: string; iv: string; ciphertext: string }): { key_id: string; iv: ArrayBuffer; ciphertext: ArrayBuffer } { + parse(payload: string | { key_id: string; iv: string; ciphertext: string }): { + key_id: string; + iv: ArrayBuffer; + ciphertext: ArrayBuffer; + } { // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} if (typeof payload !== 'string') { const iv = Base64.decode(payload.iv).buffer; @@ -755,10 +745,9 @@ export class E2ERoom extends Emitter { }; } - async decrypt(message: string | { key_id: string; iv: string; ciphertext: string }) { + async decrypt(message: string | { key_id: string; iv: string; ciphertext: string }): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { const payload = this.parse(message); - - this.log('Decrypting payload', payload); + log('info', 'Decrypting payload', payload); let oldKey = null; if (payload.key_id !== this.keyID) { @@ -774,13 +763,14 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - this.warn( + log( + 'warn', 'Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', { message: result }, ); return result; } catch (error) { - this.error('Error decrypting message: ', error, message); + log('error', 'Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; } } @@ -790,17 +780,17 @@ export class E2ERoom extends Emitter { try { if (oldKey) { const result = await this.doDecrypt(payload.iv, oldKey, payload.ciphertext); - this.log('Message decrypted', { result }); + log('info', 'Message decrypted', result); return result; } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - this.log('Message decrypted', result); + log('info', 'Message decrypted', result); return result; } catch (error) { - this.error('Error decrypting message: ', error, message); + log('error', 'Error decrypting message: ', error, message); return { msg: t('E2E_Key_Error') }; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 6f3aff5536164..f334ec52671bf 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -45,7 +45,7 @@ import { mapMessageFromApi } from '../utils/mapMessageFromApi'; let failedToDecodeKey = false; -const logger = createLogger(); +const log = createLogger('E2E'); type KeyPair = { public_key: string | null; @@ -78,7 +78,7 @@ class E2E extends Emitter { this.keyDistributionInterval = null; this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { - this.log(`${prevState} -> ${nextState}`); + log('info', 'e2eStateChanged', { prevState, nextState }); }); this.on('READY', async () => { @@ -104,14 +104,6 @@ class E2E extends Emitter { this.setState('NOT_STARTED'); } - log(...msg: unknown[]) { - logger.info('[E2E]', ...msg); - } - - error(...msg: unknown[]) { - logger.error('[E2E]', ...msg); - } - getState() { return this.state; } @@ -126,20 +118,15 @@ class E2E extends Emitter { } async onE2EEReady() { - this.log('startClient -> Done'); this.initiateHandshake(); await this.handleAsyncE2EESuggestedKey(); - this.log('decryptSubscriptions'); await this.decryptSubscriptions(); - this.log('decryptSubscriptions -> Done'); await this.initiateKeyDistribution(); - this.log('initiateKeyDistribution -> Done'); this.observeSubscriptions(); - this.log('observing subscriptions'); } async onSubscriptionChanged(sub: ISubscription) { - this.log('Subscription changed', sub); + log('info', 'subscriptionChanged', sub); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -193,7 +180,7 @@ class E2E extends Emitter { const excess = instatiated.difference(subscribed); if (excess.size) { - this.log('Unsubscribing from excess instances', excess); + log('info', 'Unsubscribing from excess instances', excess); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -231,11 +218,11 @@ class E2E extends Emitter { } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - this.log('Imported valid E2E suggested key'); + log('info', 'importedE2ESuggestedKey', sub.E2ESuggestedKey); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - this.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + log('error', 'invalidE2ESuggestedKey', sub.E2ESuggestedKey); await e2e.rejectSuggestedKey(sub.rid); } @@ -364,7 +351,7 @@ class E2E extends Emitter { return; } - this.log('startClient -> STARTED'); + log('info', 'startClient', this.state); this.started = true; @@ -394,7 +381,7 @@ class E2E extends Emitter { this.closeAlert(); }, }); - return this.error('E2E -> Error decoding private key: ', error); + return log('error', 'E2E -> Error decoding private key: ', error); } } @@ -426,7 +413,7 @@ class E2E extends Emitter { } async stopClient(): Promise { - this.log('-> Stop Client'); + log('info', 'stopClient', this.state); this.closeAlert(); Accounts.storageLocation.removeItem('public_key'); @@ -457,7 +444,7 @@ class E2E extends Emitter { this.db_private_key = private_key; } catch (error) { this.setState('ERROR'); - this.error('Error fetching RSA keys: ', error); + log('error', 'Error fetching RSA keys: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation throw error; @@ -474,7 +461,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { this.setState('ERROR'); - return this.error('Error importing private key: ', error); + return log('error', 'Error importing private key: ', error); } } @@ -487,7 +474,7 @@ class E2E extends Emitter { this.privateKey = key.privateKey; } catch (error) { this.setState('ERROR'); - return this.error('Error generating key: ', error); + return log('error', 'Error generating key: ', error); } try { @@ -497,7 +484,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { this.setState('ERROR'); - return this.error('Error exporting public key: ', error); + return log('error', 'Error exporting public key: ', error); } try { @@ -506,7 +493,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { this.setState('ERROR'); - return this.error('Error exporting private key: ', error); + return log('error', 'Error exporting private key: ', error); } await this.requestSubscriptionKeys(); @@ -535,7 +522,7 @@ class E2E extends Emitter { return EJSON.stringify(joinVectorAndEncryptedData(vector, encodedPrivateKey)); } catch (error) { this.setState('ERROR'); - return this.error('Error encrypting encodedPrivateKey: ', error); + return log('error', 'Error encrypting encodedPrivateKey: ', error); } } @@ -550,7 +537,7 @@ class E2E extends Emitter { baseKey = await importRawKey(toArrayBuffer(password)); } catch (error) { this.setState('ERROR'); - return this.error('Error creating a key based on user password: ', error); + return log('error', 'Error creating a key based on user password: ', error); } // Derive a key from the password @@ -558,7 +545,7 @@ class E2E extends Emitter { return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); } catch (error) { this.setState('ERROR'); - return this.error('Error deriving baseKey: ', error); + return log('error', 'Error deriving baseKey: ', error); } } @@ -717,7 +704,7 @@ class E2E extends Emitter { } const decryptedPinnedMessage = { ...message } as IMessage & { attachments: MessageAttachment[] }; - decryptedPinnedMessage.attachments[0].text = data.text; + decryptedPinnedMessage.attachments[0].text = typeof data.msg === 'undefined' ? data.text : data.msg; return decryptedPinnedMessage; } @@ -731,7 +718,7 @@ class E2E extends Emitter { async decryptSubscription(subscriptionId: ISubscription['_id']): Promise { const e2eRoom = await this.getInstanceByRoomId(subscriptionId); - this.log('decryptSubscription ->', subscriptionId); + log('info', 'decryptSubscription', subscriptionId); await e2eRoom?.decryptSubscription(); } @@ -886,7 +873,7 @@ class E2E extends Emitter { try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return this.error('Error providing group key to users: ', error); + return log('error', 'Error providing group key to users: ', error); } }; diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 5d03f9f10aeb9..ddbcd0d419d1d 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -18,20 +18,16 @@ export const useE2EEncryption = () => { useEffect(() => { if (!userId) { - e2e.log('Not logged in'); return; } if (!window.crypto) { - e2e.error('No crypto support'); return; } if (enabled && !adminEmbedded) { - e2e.log('E2E enabled starting client'); e2e.startClient(); } else { - e2e.log('E2E disabled'); e2e.setState('DISABLED'); e2e.closeAlert(); } @@ -48,12 +44,10 @@ export const useE2EEncryption = () => { useEffect(() => { if (!ready) { - e2e.log('Not ready'); return; } if (listenersAttachedRef.current) { - e2e.log('Listeners already attached'); return; } @@ -120,10 +114,8 @@ export const useE2EEncryption = () => { }); listenersAttachedRef.current = true; - e2e.log('Listeners attached'); return () => { - e2e.log('Not ready'); offClientMessageReceived(); offClientBeforeSendMessage(); listenersAttachedRef.current = false; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 898eee197e0da..0c0da78bc5029 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -940,7 +940,8 @@ test.describe('e2e-encryption', () => { // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports const { e2e }: typeof import('../../client/lib/e2ee/rocketchat.e2e.ts') = require('/client/lib/e2ee/rocketchat.e2e.ts'); const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom?.encrypt({ _id: 'id', msg: 'Old format message' }); + // @ts-expect-error - _id is required, but we don't need it to encrypt a message + return e2eRoom?.encrypt({ _id: 'id', msg: 'Old format message', rid }); }, rid); if (!msg) { From 483f04eb416efff3586b4899564db15b93303545 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 11 Sep 2025 15:20:30 -0300 Subject: [PATCH 148/251] fix decrypt desktop notifications --- .../server/functions/notifications/desktop.ts | 3 +++ .../notification/useDesktopNotification.ts | 8 +++++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 21 +++++-------------- packages/core-typings/src/INotification.ts | 1 + 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index f3dddd3fcf2e6..f98c149998d11 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -57,6 +57,9 @@ export async function notifyDesktopUser({ ...('t' in message && { t: message.t, }), + ...('content' in message && { + content: message.content, + }) }, name, audioNotificationValue, diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index f2cced341a3de..45ebdf8bc13c2 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -22,10 +22,14 @@ export const useDesktopNotification = () => { return; } - if (notification.payload.message?.t === 'e2e') { + const { message } = notification.payload; + + + if (message.t === 'e2e' && message.content) { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { - notification = await e2eRoom.decryptDesktopNotification(notification); + const decrypted = await e2eRoom.decrypt(message.content); + notification.text = decrypted.msg ?? decrypted.text; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index a55db45dabd12..ff4d1ab552875 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Base64 } from '@rocket.chat/base64'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast, INotificationDesktop } from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, AtLeast } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; @@ -203,17 +203,6 @@ export class E2ERoom extends Emitter { this[KEY_ID] = keyID; } - async decryptDesktopNotification(notification: INotificationDesktop): Promise { - if (!this.isReady()) { - return notification; - } - - const message = await this.decrypt(notification.payload.message.msg); - notification.text = typeof message.msg === 'undefined' ? message.text : message.msg; - - return notification; - } - async decryptSubscription() { const subscription = Subscriptions.state.find(({ rid, lastMessage }) => rid === this.roomId && !!lastMessage && lastMessage.t === 'e2e' && lastMessage.e2e === 'pending'); @@ -722,20 +711,20 @@ export class E2ERoom extends Emitter { } // Parses the message to extract the keyID and cipherText - parse(payload: string | { key_id: string; iv: string; ciphertext: string }): { + parse(payload: string | Required['content']): { key_id: string; iv: ArrayBuffer; ciphertext: ArrayBuffer; } { // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} - if (typeof payload !== 'string') { + if (typeof payload !== 'string' && payload.algorithm === 'rc.v2.aes-sha2') { const iv = Base64.decode(payload.iv).buffer; const ciphertext = Base64.decode(payload.ciphertext).buffer; return { key_id: payload.key_id, iv, ciphertext }; } // v1: key_id + base64(vector + ciphertext) - const message = payload; + const message = typeof payload === 'string' ? payload : payload.ciphertext; const key_id = message.slice(0, 12); const [iv, ciphertext] = splitVectorAndEncryptedData(Base64.decode(message.slice(12))); return { @@ -745,7 +734,7 @@ export class E2ERoom extends Emitter { }; } - async decrypt(message: string | { key_id: string; iv: string; ciphertext: string }): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { + async decrypt(message: string | Required['content']): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { const payload = this.parse(message); log('info', 'Decrypting payload', payload); diff --git a/packages/core-typings/src/INotification.ts b/packages/core-typings/src/INotification.ts index eee671b94586d..c50dc3d3a4a22 100644 --- a/packages/core-typings/src/INotification.ts +++ b/packages/core-typings/src/INotification.ts @@ -64,6 +64,7 @@ export interface INotificationDesktop { message: { msg: IMessage['msg']; t?: IMessage['t']; + content?: IMessage['content']; }; audioNotificationValue: ISubscription['audioNotificationValue']; }; From 59246e6dc8cf5ead1866c360a0d2848f3113af97 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 11 Sep 2025 18:29:57 -0300 Subject: [PATCH 149/251] improve logging --- apps/meteor/client/lib/e2ee/logger.ts | 12 +++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 50 ++++++++----------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 5 +- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 39d4aba0e78e7..160313223a1af 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -6,10 +6,16 @@ const styles: Record = { error: 'color: red; background-color: white; font-weight: bold;', }; +const icon: Record = { + info: 'ⓘ', + warn: '⚠️', + error: '❌', +}; + export const createLogger = (label: string) => { - return (level: LogLevel, ...[first, ...msg]: [string, ...unknown[]]) => { - console.groupCollapsed(`%c[${level}][${label}]`, styles[level], first); - console.trace(...msg); + return (level: LogLevel, title: string, message: unknown, ...params: unknown[]) => { + console.groupCollapsed(`${icon[level]} %c[${label}:${title}]`, styles[level]); + console.trace(message, ...params); console.groupEnd(); }; }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index ff4d1ab552875..fa9df7ee034d0 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -207,7 +207,7 @@ export class E2ERoom extends Emitter { const subscription = Subscriptions.state.find(({ rid, lastMessage }) => rid === this.roomId && !!lastMessage && lastMessage.t === 'e2e' && lastMessage.e2e === 'pending'); if (subscription?.lastMessage?.t !== 'e2e') { - log('info', 'decryptSubscriptions nothing to do'); + log('info', 'decryptSubscriptions', 'Nothing to do'); return; } @@ -404,29 +404,24 @@ export class E2ERoom extends Emitter { } async createGroupKey() { - try { - await this.createNewGroupKey(); - - await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); - if (myKey) { - await sdk.rest.post('/v1/e2e.updateGroupKey', { - rid: this.roomId, - uid: this.userId, - key: myKey, - }); - await this.encryptKeyForOtherParticipants(); - } - } catch (error) { - log('error', 'Error exporting group key: ', error); - throw error; + await this.createNewGroupKey(); + + await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); + const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); + if (myKey) { + await sdk.rest.post('/v1/e2e.updateGroupKey', { + rid: this.roomId, + uid: this.userId, + key: myKey, + }); + await this.encryptKeyForOtherParticipants(); } } async resetRoomKey() { - log('info', 'Resetting room key', { currentKeyID: this.keyID }); + log('info', 'resetRoomKey', 'started', { currentKeyID: this.keyID }); if (!e2e.publicKey) { - log('error', 'Cannot reset room key. No public key found.', { e2e }); + log('error', 'Cannot reset room key', 'No public key found.', e2e); return; } @@ -437,17 +432,17 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - log('info', 'Room key reset successfully', e2eNewKeys); + log('info', 'Room key', 'reset successfully', e2eNewKeys); return e2eNewKeys; } catch (error) { - log('error', 'Error resetting group key: ', error); + log('error', 'resetRoomKey', 'failed', error); throw error; } } onRoomKeyReset(keyID: string) { - log('info', 'Room keyID was reset', { NewKeyID: keyID, PreviousKeyID: this.keyID }); + log('info', 'onRoomKeyReset', 'old:', this.keyID, 'new:', keyID); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -522,7 +517,7 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return log('error', 'Error encrypting user key: ', error); + return log('error', 'failed to encrypt old keys for participant', error); } } @@ -531,7 +526,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return log('error', 'Error importing user key: ', error); + return log('error', 'importing user key', error); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -683,7 +678,6 @@ export class E2ERoom extends Emitter { // Decrypt messages async decryptMessage(message: IMessage | IE2EEMessage): Promise { - log('info', 'decryptMessage', message); if (message.t !== 'e2e' || message.e2e === 'done') { return message; } @@ -707,6 +701,7 @@ export class E2ERoom extends Emitter { async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date }> { const result = await decryptAes(vector, key, cipherText); const parsed = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); + log('info', 'decrypted', parsed); return parsed; } @@ -736,7 +731,6 @@ export class E2ERoom extends Emitter { async decrypt(message: string | Required['content']): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { const payload = this.parse(message); - log('info', 'Decrypting payload', payload); let oldKey = null; if (payload.key_id !== this.keyID) { @@ -769,17 +763,15 @@ export class E2ERoom extends Emitter { try { if (oldKey) { const result = await this.doDecrypt(payload.iv, oldKey, payload.ciphertext); - log('info', 'Message decrypted', result); return result; } if (!this.groupSessionKey) { throw new Error('No group session key found.'); } const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - log('info', 'Message decrypted', result); return result; } catch (error) { - log('error', 'Error decrypting message: ', error, message); + log('error', 'decrypting', error, message); return { msg: t('E2E_Key_Error') }; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index f334ec52671bf..51132cd64d3c5 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -77,10 +77,6 @@ class E2E extends Emitter { this.instancesByRoomId = {}; this.keyDistributionInterval = null; - this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { - log('info', 'e2eStateChanged', { prevState, nextState }); - }); - this.on('READY', async () => { await this.onE2EEReady(); }); @@ -200,6 +196,7 @@ class E2E extends Emitter { this.state = nextState; + log('info', 'e2eStateChanged', { prevState, nextState }); this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); From fb68b211ed90478de2e452be04c5f8d2313f345b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 11 Sep 2025 19:08:02 -0300 Subject: [PATCH 150/251] improve tracing --- apps/meteor/client/lib/e2ee/logger.ts | 3 ++- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 4 ++-- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 160313223a1af..7a37c69902795 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -15,7 +15,8 @@ const icon: Record = { export const createLogger = (label: string) => { return (level: LogLevel, title: string, message: unknown, ...params: unknown[]) => { console.groupCollapsed(`${icon[level]} %c[${label}:${title}]`, styles[level]); - console.trace(message, ...params); + console.log(message, ...params); + console.trace(); console.groupEnd(); }; }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index fa9df7ee034d0..113369b08cc4d 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -125,12 +125,12 @@ export class E2ERoom extends Emitter { const nextState = filterMutation(currentState, requestedState); if (!nextState) { - log('error', 'setState', { currentState, requestedState }); + log('error', 'setState', `${currentState} -> ${requestedState}`); return; } this.state = nextState; - log('info', 'setState', { currentState, nextState }); + log('info', 'setState', `${currentState} -> ${nextState}`); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 51132cd64d3c5..448eb3f37ee80 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -138,7 +138,7 @@ class E2E extends Emitter { await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + log('warn', 'Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); await this.rejectSuggestedKey(sub.rid); } } @@ -196,7 +196,7 @@ class E2E extends Emitter { this.state = nextState; - log('info', 'e2eStateChanged', { prevState, nextState }); + log('info', 'e2eStateChanged', `${prevState} -> ${nextState}`); this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); From 667aee02dce840deb58aa8c8d3a5f4e68453cd0b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 12 Sep 2025 13:23:04 -0300 Subject: [PATCH 151/251] upgrade mnemonic word count to 12 --- apps/meteor/client/lib/e2ee/helper.ts | 6 + apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 +- apps/meteor/client/lib/e2ee/wordList.ts | 2048 +++++++++++++++++ 3 files changed, 2056 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/wordList.ts diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 912855e68df33..9f942a7b9b509 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -175,6 +175,12 @@ export function readFileAsArrayBuffer(file: File) { }); } +export const generateMnemonicPhrase = async (length: number): Promise => { + const { default: wordList} = await import('./wordList'); + const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(randomBuffer, (value) => wordList[value % wordList.length]).join(' '); +}; + export async function createSha256HashFromText(data: string) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hash)) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 448eb3f37ee80..2194ff21556cf 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -3,7 +3,6 @@ import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { generateMnemonicPhrase } from '@rocket.chat/e2ee'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import EJSON from 'ejson'; @@ -24,6 +23,7 @@ import { importRSAKey, importRawKey, deriveKey, + generateMnemonicPhrase, } from './helper'; import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -501,7 +501,7 @@ class E2E extends Emitter { } async createRandomPassword(): Promise { - const randomPassword = await generateMnemonicPhrase(5); + const randomPassword = await generateMnemonicPhrase(12); Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); return randomPassword; } diff --git a/apps/meteor/client/lib/e2ee/wordList.ts b/apps/meteor/client/lib/e2ee/wordList.ts new file mode 100644 index 0000000000000..d6c205d9e43a0 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/wordList.ts @@ -0,0 +1,2048 @@ +export default `abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo`.split('\n'); From 973653335713eb1d6ccaa2fcf1d55384513d8f52 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 12 Sep 2025 13:25:09 -0300 Subject: [PATCH 152/251] remove separate package --- .../meteor/client/lib/e2ee/README.md | 59 +- packages/e2ee/.oxlintrc.json | 31 - packages/e2ee/docs/state_v1.mmd | 71 - packages/e2ee/globals.d.ts | 73 - packages/e2ee/package.json | 43 - packages/e2ee/playground/.oxlintrc.json | 5 - packages/e2ee/playground/messages.mongodb.js | 6 - packages/e2ee/scripts/postbuild.ts | 6 - packages/e2ee/src/__tests__/.oxlintrc.json | 5 - packages/e2ee/src/__tests__/base64.spec.ts | 28 - packages/e2ee/src/__tests__/binary.spec.ts | 22 - packages/e2ee/src/__tests__/group.spec.ts | 121 - packages/e2ee/src/__tests__/jwk.spec.ts | 53 - packages/e2ee/src/__tests__/mock/server.ts | 126 - packages/e2ee/src/__tests__/pbkdf2.spec.ts | 8 - packages/e2ee/src/__tests__/rsa.spec.ts | 72 - packages/e2ee/src/__tests__/vector.spec.ts | 47 - packages/e2ee/src/aes.ts | 32 - packages/e2ee/src/base64.ts | 38 - packages/e2ee/src/binary.ts | 28 - packages/e2ee/src/crypto.ts | 190 -- packages/e2ee/src/group.ts | 272 --- packages/e2ee/src/index.ts | 257 --- packages/e2ee/src/jwk.ts | 69 - packages/e2ee/src/keychain.ts | 96 - packages/e2ee/src/pbkdf2.ts | 46 - packages/e2ee/src/result.ts | 15 - packages/e2ee/src/rsa.ts | 28 - packages/e2ee/src/vector.ts | 23 - packages/e2ee/src/wordlists/v1.ts | 1633 ------------- packages/e2ee/src/wordlists/v2.ts | 2048 ----------------- packages/e2ee/tsconfig.build.json | 7 - packages/e2ee/tsconfig.json | 47 - packages/e2ee/turbo.json | 10 - packages/e2ee/vitest.config.ts | 25 - 35 files changed, 1 insertion(+), 5639 deletions(-) rename packages/e2ee/docs/NOTES.md => apps/meteor/client/lib/e2ee/README.md (59%) delete mode 100644 packages/e2ee/.oxlintrc.json delete mode 100644 packages/e2ee/docs/state_v1.mmd delete mode 100644 packages/e2ee/globals.d.ts delete mode 100644 packages/e2ee/package.json delete mode 100644 packages/e2ee/playground/.oxlintrc.json delete mode 100644 packages/e2ee/playground/messages.mongodb.js delete mode 100644 packages/e2ee/scripts/postbuild.ts delete mode 100644 packages/e2ee/src/__tests__/.oxlintrc.json delete mode 100644 packages/e2ee/src/__tests__/base64.spec.ts delete mode 100644 packages/e2ee/src/__tests__/binary.spec.ts delete mode 100644 packages/e2ee/src/__tests__/group.spec.ts delete mode 100644 packages/e2ee/src/__tests__/jwk.spec.ts delete mode 100644 packages/e2ee/src/__tests__/mock/server.ts delete mode 100644 packages/e2ee/src/__tests__/pbkdf2.spec.ts delete mode 100644 packages/e2ee/src/__tests__/rsa.spec.ts delete mode 100644 packages/e2ee/src/__tests__/vector.spec.ts delete mode 100644 packages/e2ee/src/aes.ts delete mode 100644 packages/e2ee/src/base64.ts delete mode 100644 packages/e2ee/src/binary.ts delete mode 100644 packages/e2ee/src/crypto.ts delete mode 100644 packages/e2ee/src/group.ts delete mode 100644 packages/e2ee/src/index.ts delete mode 100644 packages/e2ee/src/jwk.ts delete mode 100644 packages/e2ee/src/keychain.ts delete mode 100644 packages/e2ee/src/pbkdf2.ts delete mode 100644 packages/e2ee/src/result.ts delete mode 100644 packages/e2ee/src/rsa.ts delete mode 100644 packages/e2ee/src/vector.ts delete mode 100644 packages/e2ee/src/wordlists/v1.ts delete mode 100644 packages/e2ee/src/wordlists/v2.ts delete mode 100644 packages/e2ee/tsconfig.build.json delete mode 100644 packages/e2ee/tsconfig.json delete mode 100644 packages/e2ee/turbo.json delete mode 100644 packages/e2ee/vitest.config.ts diff --git a/packages/e2ee/docs/NOTES.md b/apps/meteor/client/lib/e2ee/README.md similarity index 59% rename from packages/e2ee/docs/NOTES.md rename to apps/meteor/client/lib/e2ee/README.md index 34ca174a45149..74b47bea0b2cf 100644 --- a/packages/e2ee/docs/NOTES.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -39,61 +39,4 @@ - [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) - [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) - [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) -- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) - -## Tasks - -### Shared Package -- Separate all client-side e2ee logic from core into zero-dependency [package](../src/index.ts). - -### Refactor Web - -#### Maintainance/Interop -- Remove use of non-javascript constructs: - - `enum` -> `literal types`. - - `private member` -> `#member`. - - Use `localStorage` and/or `sessionStorage` explicitly instead of: - [Accounts.storageLocation](../../../apps/meteor/definition/externals/meteor/accounts-base.d.ts). - [Meteor._localStorage](../../../apps/meteor/app/ui-master/server/scripts.ts) - -#### Dependency Cleanup -- Remove use of `ejson` for serializing/deserializing Uint8Array: [codec.ts](../src/codec.ts#L165-L175) - - It is simply a JSON with a "$binary" field containing the base64 encoded data. -- Remove use of `bytebuffer` for encoding/decoding strings: [binary.ts](../src/binary.ts) - - ByteBuffer.wrap('hello', 'binary').toArrayBuffer() - - ByteBuffer.wrap('hello').toString('binary') - -#### Improve Tests -- Tests in [e2e-encryption.ts](../../../apps/meteor/tests/e2e/e2e-encryption.spec.ts) are failing when ran: - - More than once: lack of cleanup - - Out of order: inter-test dependencies - - Locally: slowness of CI + retries are masking bugs (eg: in full page loads) - -### Introduce AES-GCM -- Add AES‑GCM primitives alongside AES‑CBC -- Introduce a versioned “envelope” that records the cipher mode -- Default new writes to AES‑GCM; keep multi‑strategy decryption with fallback to AES‑CBC -- Update key derivation to produce AES keys for either mode -- Add tests for both modes and for mixed-mode migration - -#### The compatibility strategy (WIP) -1. Versioned envelope - - V1: `{ version: '1', mode: 'AES-CBC', ivB64, ctB64, kdf: {…} }` - - V2: `{ version: '2', mode: 'AES-GCM', ivB64, ctB64, tagLen, aadB64?, kdf: { ... } }` -2. Dual-read, single-write - - On decode, try: - - If parseable as JSON and has version/mode: - - If `mode === AES‑GCM`: decrypt with AES‑GCM - - If `mode === AES‑CBC`: decrypt with AES‑CBC (existing code) - - Else (legacy “vector + ciphertext” blob): - - Treat as AES‑CBC with current [split/join helpers](../src/vector.ts). - - On encode, default to `AES‑GCM` for new data. - - All readers will still accept older `CBC` payloads. - - New payloads advertise `GCM`. -3. Derivation and key types - - WebCrypto requires the CryptoKey algorithm.name to match the operation (CBC vs GCM). - - Add a generic derivation that yields raw bits, then import for the required algorithm: - - deriveBits with PBKDF2: - - `importKey('raw', …, { name: 'AES-GCM'|'AES-CBC', ... }, false, ['encrypt','decrypt'])` - - Keep existing CBC derivation for old data. - - For new GCM data, import the same bits as AES‑GCM. \ No newline at end of file +- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) \ No newline at end of file diff --git a/packages/e2ee/.oxlintrc.json b/packages/e2ee/.oxlintrc.json deleted file mode 100644 index 25081681a8410..0000000000000 --- a/packages/e2ee/.oxlintrc.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "env": { - "shared-node-browser": true - }, - "categories": { - "correctness": "error", - "suspicious": "error", - "perf": "error", - "restriction": "error", - "style": "error", - "nursery": "error", - "pedantic": "error" - }, - "rules": { - "oxc/no-async-await": "allow", - "eslint/id-length": "allow", - "eslint/no-undef": "allow", - "eslint/sort-keys": "allow", - "eslint/no-plusplus": "allow", - "eslint/no-magic-numbers": "allow", - "eslint/no-ternary": "allow", - "unicorn/prefer-code-point": "allow", - "unicorn/prefer-add-event-listener": "allow", - "eslint/max-lines": "allow", - "eslint/max-classes-per-file": "allow", - "no-extraneous-class": "allow", - "no-console": "allow", - "no-rest-spread-properties": "allow", - "typescript/unbound-method": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee/docs/state_v1.mmd b/packages/e2ee/docs/state_v1.mmd deleted file mode 100644 index beb7be8c6cb01..0000000000000 --- a/packages/e2ee/docs/state_v1.mmd +++ /dev/null @@ -1,71 +0,0 @@ -sequenceDiagram - autonumber - participant U as User Client - participant RS as Room State - participant Sub as Subscriptions Store - participant API as REST API - participant KMS as Key Source (peer/creator) - - Note over RS: Initial load - U->>RS: Open room - RS->>RS: state = NOT_STARTED - - alt Existing key present - Sub-->>RS: E2EKey (room subscription) - RS->>RS: state = KEYS_RECEIVED - else Suggested key rotation - Sub-->>RS: E2ESuggestedKey - RS->>RS: importSuggestedKey() - RS-->>API: POST /v1/e2e.acceptSuggestedGroupKey - RS->>RS: state = KEYS_RECEIVED - else No key yet - RS->>RS: remain NOT_STARTED - end - - U->>RS: handshake() - RS->>RS: Guard: require state in {NOT_STARTED, KEYS_RECEIVED} & e2e.isReady() - RS->>RS: state = ESTABLISHING - - opt Fast-path import - Sub-->>RS: (lookup) E2EKey - RS->>RS: importGroupKey() - RS->>RS: state = READY - RS-->>U: Handshake complete - end - - alt No existing key - RS-->>API: Request /v1/e2e.requestSubscriptionKeys - API-->>RS: 404 / no material - RS->>RS: state = CREATING_KEYS - RS->>RS: generate group key - RS-->>API: publish new key - RS->>RS: state = READY - RS-->>U: Handshake complete - else Key arrives late - par Parallel events - API-->>RS: E2EKey via subscription update - RS-->>RS: state = KEYS_RECEIVED - and RS->>RS: continuing ESTABLISHING - end - RS->>RS: importGroupKey() (idempotent) - RS->>RS: state = READY - RS-->>U: Handshake complete - end - - rect rgba(255,200,0,0.15) - note over RS: ERROR path - RS-->>RS: on fetch/import error -> state = ERROR - Sub-->>RS: Later E2EKey - RS->>RS: state = KEYS_RECEIVED - U->>RS: handshake() retry - end - - rect rgba(150,200,255,0.15) - note over RS: Key rotation - Sub-->>RS: E2ESuggestedKey - RS->>RS: importGroupKey() (new keyID) - RS-->>API: acceptSuggestedGroupKey - RS->>RS: state = KEYS_RECEIVED - U->>RS: handshake() (re-establish) - RS->>RS: state = ESTABLISHING -> READY - end \ No newline at end of file diff --git a/packages/e2ee/globals.d.ts b/packages/e2ee/globals.d.ts deleted file mode 100644 index c7284376cd52c..0000000000000 --- a/packages/e2ee/globals.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -interface JSON { - parse(text: string): unknown; - stringify(data: unknown): string; -} - -interface Uint8ArrayConstructor { - /** - * Creates a new `Uint8Array` from a base64-encoded string. - * @param string The base64-encoded string. - * @param options If provided, specifies the alphabet and handling of the last chunk. - * @returns A new `Uint8Array` instance. - * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last - * chunk is inconsistent with the `lastChunkHandling` option. - */ - fromBase64?( - this: void, - string: string, - options?: { - alphabet?: 'base64' | 'base64url'; - lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial'; - }, - ): Uint8Array; - - /** - * Creates a new `Uint8Array` from a base16-encoded string. - * @returns A new `Uint8Array` instance. - */ - fromHex(string: string): Uint8Array; -} - -interface Uint8Array { - /** - * Converts the `Uint8Array` to a base64-encoded string. - * @param options If provided, sets the alphabet and padding behavior used. - * @returns A base64-encoded string. - */ - toBase64?(options?: { alphabet?: 'base64' | 'base64url'; omitPadding?: boolean }): string; - - /** - * Sets the `Uint8Array` from a base64-encoded string. - * @param string The base64-encoded string. - * @param options If provided, specifies the alphabet and handling of the last chunk. - * @returns An object containing the number of bytes read and written. - * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last - * chunk is inconsistent with the `lastChunkHandling` option. - */ - setFromBase64?( - string: string, - options?: { - alphabet?: 'base64' | 'base64url'; - lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial'; - }, - ): { - read: number; - written: number; - }; - - /** - * Converts the `Uint8Array` to a base16-encoded string. - * @returns A base16-encoded string. - */ - toHex(): string; - - /** - * Sets the `Uint8Array` from a base16-encoded string. - * @param string The base16-encoded string. - * @returns An object containing the number of bytes read and written. - */ - setFromHex(string: string): { - read: number; - written: number; - }; -} diff --git a/packages/e2ee/package.json b/packages/e2ee/package.json deleted file mode 100644 index f51c22a4db9b0..0000000000000 --- a/packages/e2ee/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@rocket.chat/e2ee", - "private": true, - "version": "0.1.0", - "description": "Runtime-agnostic, sans-IO end-to-end encryption core for Rocket.Chat (Double Ratchet, key management, message formats)", - "type": "module", - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "sideEffects": false, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "postbuild": "node --experimental-strip-types ./scripts/postbuild.ts", - "typecheck": "tsc -p tsconfig.json", - "lint": "oxlint --type-aware", - "testunit": "vitest run --browser=chromium --coverage", - "test": "vitest" - }, - "devDependencies": { - "@vitest/browser": "4.0.0-beta.10", - "@vitest/coverage-v8": "4.0.0-beta.10", - "@vitest/ui": "4.0.0-beta.10", - "oxlint": "~1.14.0", - "oxlint-tsgolint": "~0.1.5", - "playwright": "^1.55.0", - "typescript": "~5.9.2", - "vitest": "4.0.0-beta.10" - }, - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/packages/e2ee/playground/.oxlintrc.json b/packages/e2ee/playground/.oxlintrc.json deleted file mode 100644 index 329fae1135eb5..0000000000000 --- a/packages/e2ee/playground/.oxlintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "no-magic-numbers": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee/playground/messages.mongodb.js b/packages/e2ee/playground/messages.mongodb.js deleted file mode 100644 index 9083bf045f3a7..0000000000000 --- a/packages/e2ee/playground/messages.mongodb.js +++ /dev/null @@ -1,6 +0,0 @@ -use('meteor'); -db.getCollection('rocketchat_message').find({ - // 'content.algorithm': { $exists: true }, - // 'content.ciphertext': { $exists: true }, - t: { $eq: 'e2e' }, -}); diff --git a/packages/e2ee/scripts/postbuild.ts b/packages/e2ee/scripts/postbuild.ts deleted file mode 100644 index be23ae772367e..0000000000000 --- a/packages/e2ee/scripts/postbuild.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { writeFile } from 'node:fs/promises'; - -// Create the CJS module entry point -// Require the E2EE class from the ESM module - -await writeFile('./dist/index.cjs', "module.exports = require('./index.js');\n"); diff --git a/packages/e2ee/src/__tests__/.oxlintrc.json b/packages/e2ee/src/__tests__/.oxlintrc.json deleted file mode 100644 index 329fae1135eb5..0000000000000 --- a/packages/e2ee/src/__tests__/.oxlintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "no-magic-numbers": "allow" - } -} \ No newline at end of file diff --git a/packages/e2ee/src/__tests__/base64.spec.ts b/packages/e2ee/src/__tests__/base64.spec.ts deleted file mode 100644 index a8c617bf57f2f..0000000000000 --- a/packages/e2ee/src/__tests__/base64.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test, describe } from 'vitest'; -import { parseUint8Array, stringifyUint8Array, fromB64, toB64, fromB64Fallback, toB64Fallback } from '../base64.ts'; - -describe.for([ - { fromB64, toB64 }, - { fromB64: fromB64Fallback, toB64: toB64Fallback }, -])('Base64 (%s)', ({ fromB64, toB64 }) => { - test.for([ - ['', []], - ['AQID', [1, 2, 3]], - ['x+/y', [199, 239, 242]], - ['ZXhhZg==', [101, 120, 97, 102]], - ['AAECAwQFBgcICQ==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], - ['AAECAwQFBgcICQoLDA0ODw==', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], - ['AQIDBAUGBwgJCgsMDQ4P', [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]], - ['SGVsbG8gV29ybGQ=', [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]], - ] as const)('"%s" === [%s]', ([string, bytes]) => { - const uint8array = new Uint8Array(bytes); - expect(fromB64(string)).toEqual(uint8array); - expect(toB64(uint8array)).toBe(string); - expect(stringifyUint8Array(uint8array)).toBe(`{"$binary":"${string}"}`); - expect(parseUint8Array(`{"$binary":"${string}"}`)).toEqual(uint8array); - }); - - test('Invalid parseUint8Array', () => { - expect(() => parseUint8Array('[]')).toThrow(); - }); -}); diff --git a/packages/e2ee/src/__tests__/binary.spec.ts b/packages/e2ee/src/__tests__/binary.spec.ts deleted file mode 100644 index b9604907e8463..0000000000000 --- a/packages/e2ee/src/__tests__/binary.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from 'vitest'; -import { toArrayBuffer, toString } from '../binary.ts'; - -test('toArrayBuffer', () => { - expect(toArrayBuffer('')).toMatchInlineSnapshot(`Uint8Array []`); - expect(toArrayBuffer('hello')).toMatchInlineSnapshot(` - Uint8Array [ - 104, - 101, - 108, - 108, - 111, - ] - `); -}); - -test('toString', () => { - expect(toString(new Uint8Array([]).buffer)).toMatchInlineSnapshot(`""`); - expect(toString(new Uint8Array([104, 101, 108, 108, 111]).buffer)).toMatchInlineSnapshot(` - "hello" - `); -}); diff --git a/packages/e2ee/src/__tests__/group.spec.ts b/packages/e2ee/src/__tests__/group.spec.ts deleted file mode 100644 index 9d3757693da08..0000000000000 --- a/packages/e2ee/src/__tests__/group.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { EncryptionClient } from '../group'; -import { MockChatService } from './mock/server'; - -describe('EncryptionClient', () => { - let mockService: MockChatService; - - beforeEach(() => { - // Reset the mock service and IndexedDB before each test - mockService = new MockChatService(); - }); - - describe('User Management', () => { - it('should register a new user and store their data via the service', async () => { - const userId = 'alice@example.com'; - const password = 'password123'; - const user = await EncryptionClient.initialize(userId, mockService); - - const spy = vi.spyOn(mockService, 'registerUser'); - await user.register(password); - - // Check that the library called the service correctly - expect(spy).toHaveBeenCalledOnce(); - const userData = await mockService.getUserData(userId); - expect(userData).toBeDefined(); - expect(userData?.publicKeyInfo.format).toBe('jwk'); - }); - - it('should log in a user with a passphrase if key is not local', async () => { - const chatService = new MockChatService(); - const userId = 'bob@example.com'; - const password = 'password-bob'; - const registrant = await EncryptionClient.initialize(userId, chatService); - - // 1. Bob registers on one device, populating the service. - await registrant.register(password); - - // 2. Bob tries to log in on a *new device* (simulated by a new instance and empty IndexedDB). - const loginUser = await EncryptionClient.initialize(userId, chatService); - await loginUser.login(password); - - // @ts-expect-error - Accessing private property for test verification - expect(loginUser.userId).toBe(userId); - // @ts-expect-error - expect(loginUser.privateKey).not.toBeNull(); - }); - - it('should permanently delete the account from the device storage', async () => { - const userId = 'frank@example.com'; - const user = await EncryptionClient.initialize(userId, mockService); - await user.register('password-frank'); - - await user.deleteAccountFromDevice(); - - // Verify in-memory session is cleared - // @ts-expect-error - expect(user.userId).toBe(''); - - // Verify login fails without a passphrase, proving the key is gone - const userInNewSession = await EncryptionClient.initialize(userId, mockService); - await expect(userInNewSession.login()).rejects.toThrow('Private key not found locally. Passphrase is required for recovery.'); - }); - }); - - describe('Full Group Chat End-to-End Flow', () => { - it('should allow users to create groups, send messages, and for new users to decrypt history', async () => { - // --- SETUP --- - // 1. Alice, Bob, and Carol register with the same chat service. - const alice = await EncryptionClient.initialize('alice', mockService); - const bob = await EncryptionClient.initialize('bob', mockService); - const carol = await EncryptionClient.initialize('carol', mockService); - - await alice.register('alice-pass'); - await bob.register('bob-pass'); - await carol.register('carol-pass'); - - const groupId = 'tech-enthusiasts'; - - // --- GROUP CREATION --- - // 2. Alice creates a group and invites Bob. - await alice.createGroup(groupId, ['bob']); - - // --- SEND & RECEIVE --- - // 3. Alice sends some messages to the group. - await alice.sendMessage(groupId, 'Hello Bob!'); - await alice.sendMessage(groupId, 'How is the project going?'); - - // 4. Bob logs in on his device and joins the group. - // In a real app, Bob would get a notification. Here we simulate it directly. - await bob.joinGroup(groupId); - - // 5. Bob fetches the messages and decrypts them. - const bobsMessages = await bob.getMessages(groupId); - expect(bobsMessages).toEqual(['Hello Bob!', 'How is the project going?']); - - // --- ADDING A NEW USER --- - // 6. Alice decides to add Carol to the group. - await alice.addUserToGroup(groupId, 'carol'); - - // 7. Carol joins the group. - await carol.joinGroup(groupId); - - // 8. Carol fetches the *entire message history* and decrypts it. - // This confirms a core requirement: new users have access to past messages. - const carolsMessages = await carol.getMessages(groupId); - expect(carolsMessages).toEqual(['Hello Bob!', 'How is the project going?']); - - // 9. Bob sends a new message. - await bob.sendMessage(groupId, 'Hi Alice and Carol! Project is going great.'); - - // 10. Alice and Carol fetch the latest messages. - const alicesFinalMessages = await alice.getMessages(groupId); - const carolsFinalMessages = await carol.getMessages(groupId); - - const expectedFinalHistory = ['Hello Bob!', 'How is the project going?', 'Hi Alice and Carol! Project is going great.']; - - expect(alicesFinalMessages).toEqual(expectedFinalHistory); - expect(carolsFinalMessages).toEqual(expectedFinalHistory); - }); - }); -}); diff --git a/packages/e2ee/src/__tests__/jwk.spec.ts b/packages/e2ee/src/__tests__/jwk.spec.ts deleted file mode 100644 index a1f69f1924a08..0000000000000 --- a/packages/e2ee/src/__tests__/jwk.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as Jwk from '../jwk.ts'; -import { expect, test } from 'vitest'; - -test('parse JWK', async () => { - const jwk = Jwk.parse( - '{"kty":"RSA","e":"AQAB","n":"sXchm3y8v4j5b0kV7c1u5rX2a6pY9H3j5x7v9y3z4w5v6y7z8x9y0z1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7"}', - ); - expect(Jwk.is(jwk)).toBe(true); -}); - -test('invalid Json', async () => { - expect(() => Jwk.parse('{"kty":"RSA","e":"AQAB","n":123}')).toThrow(); -}); - -test('invalid JWK', async () => { - const jwk = { - kty: 'oct', - }; - expect(Jwk.is(jwk)).toBe(false); -}); - -test('AES-CBC', async () => { - const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); - const jwk = await crypto.subtle.exportKey('jwk', key); - - expect(Jwk.is(jwk)).toBe(true); - expect(Jwk.isAesCbc(jwk)).toBe(true); -}); - -test('AES-GCM', async () => { - const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - const jwk = await crypto.subtle.exportKey('jwk', key); - expect(Jwk.is(jwk)).toBe(true); - expect(Jwk.isAesGcm(jwk)).toBe(true); -}); - -test('RSA-OAEP', async () => { - const key = await crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt'], - ); - - const publicJwk = await crypto.subtle.exportKey('jwk', key.publicKey); - - expect(Jwk.is(publicJwk)).toBe(true); - expect(Jwk.isRsaOaep(publicJwk)).toBe(true); - - const privateJwk = await crypto.subtle.exportKey('jwk', key.privateKey); - - expect(Jwk.is(privateJwk)).toBe(true); - expect(Jwk.isRsaOaep(privateJwk)).toBe(true); -}); diff --git a/packages/e2ee/src/__tests__/mock/server.ts b/packages/e2ee/src/__tests__/mock/server.ts deleted file mode 100644 index 3ff96ba6874a2..0000000000000 --- a/packages/e2ee/src/__tests__/mock/server.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { EncryptedMessage, EncryptedPrivateKeyBundle, PublicKeyInfo } from '../../crypto'; -import type { EncryptedGroupKeyInfo, ChatService } from '../../group'; - -/** - * A mock backend chat service for testing purposes. - * This class simulates a server's database in memory to manage users, - * groups, and messages, allowing for end-to-end testing of the crypto library. - */ -export class MockChatService implements ChatService { - // Simulates a 'users' table in a database - private users = new Map< - string, - { - publicKeyInfo: PublicKeyInfo; - privateKeyBundle: EncryptedPrivateKeyBundle; - } - >(); - - // Simulates a 'groups' table - private groups = new Map< - string, - { - members: Set; - encryptedGroupKeys: Map; - } - >(); - - // Simulates a message store for each group - private messages = new Map(); - - /** - * Simulates registering a user by storing their public key and encrypted private key bundle. - */ - public async registerUser(userId: string, publicKeyInfo: PublicKeyInfo, privateKeyBundle: EncryptedPrivateKeyBundle): Promise { - if (this.users.has(userId)) { - throw new Error(`User ${userId} already exists.`); - } - this.users.set(userId, { publicKeyInfo, privateKeyBundle }); - } - - /** - * Simulates fetching user data required for login or recovery. - */ - public async getUserData(userId: string): Promise< - | { - publicKeyInfo: PublicKeyInfo; - privateKeyBundle: EncryptedPrivateKeyBundle; - } - | undefined - > { - return this.users.get(userId); - } - - /** - * Simulates fetching multiple users' public keys. - */ - public async findUsersPublicKeys(userIds: string[]): Promise> { - const result = new Map(); - for (const userId of userIds) { - result.set(userId, await this.findUserPublicKey(userId)); - } - return result; - } - - /** - * Simulates fetching a user's public key, which is needed to add them to a group. - */ - public async findUserPublicKey(userId: string): Promise { - return this.users.get(userId)?.publicKeyInfo; - } - - /** - * Simulates creating a new group chat. - */ - public async createGroup(groupId: string, initialEncryptedKeys: EncryptedGroupKeyInfo[]): Promise { - if (this.groups.has(groupId)) { - throw new Error(`Group ${groupId} already exists.`); - } - - const members = new Set(); - const encryptedGroupKeys = new Map(); - - for (const keyInfo of initialEncryptedKeys) { - members.add(keyInfo.userId); - encryptedGroupKeys.set(keyInfo.userId, keyInfo); - } - - this.groups.set(groupId, { members, encryptedGroupKeys }); - } - - /** - * Simulates adding a new user to an existing group. - */ - public async addUserToGroup(groupId: string, newMemberKeyInfo: EncryptedGroupKeyInfo): Promise { - const group = this.groups.get(groupId); - if (!group) { - throw new Error(`Group ${groupId} not found.`); - } - group.members.add(newMemberKeyInfo.userId); - group.encryptedGroupKeys.set(newMemberKeyInfo.userId, newMemberKeyInfo); - } - - /** - * Simulates a user fetching their specific encrypted group key to join a group. - */ - public async getGroupKeyForUser(groupId: string, userId: string): Promise { - return this.groups.get(groupId)?.encryptedGroupKeys.get(userId); - } - - /** - * Simulates posting an encrypted message to a group's message log. - */ - public async postMessage(groupId: string, message: EncryptedMessage): Promise { - if (!this.messages.has(groupId)) { - this.messages.set(groupId, []); - } - this.messages.get(groupId)?.push(message); - } - - /** - * Simulates fetching the entire message history for a group. - */ - public async getMessages(groupId: string): Promise { - return this.messages.get(groupId) || []; - } -} diff --git a/packages/e2ee/src/__tests__/pbkdf2.spec.ts b/packages/e2ee/src/__tests__/pbkdf2.spec.ts deleted file mode 100644 index 5338107d23e64..0000000000000 --- a/packages/e2ee/src/__tests__/pbkdf2.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from 'vitest'; -import { derivePbkdf2Key, importPbkdf2Key } from '../pbkdf2.ts'; - -test('pbkdf2', async () => { - const baseKey = await importPbkdf2Key('passphrase'); - const derived = await derivePbkdf2Key('salt', baseKey); - expect(derived.algorithm.name).toBe('AES-GCM'); -}); diff --git a/packages/e2ee/src/__tests__/rsa.spec.ts b/packages/e2ee/src/__tests__/rsa.spec.ts deleted file mode 100644 index b6836c92bdfce..0000000000000 --- a/packages/e2ee/src/__tests__/rsa.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { test, expect } from 'vitest'; -import { generateRsaOaepKeyPair, importRsaOaepKey, exportRsaOaepKey, decryptRsaOaep, encryptRsaOaep } from '../rsa'; - -const encode = (s: string) => new TextEncoder().encode(s); -const decode = (b: ArrayBuffer | Uint8Array) => new TextDecoder().decode(b instanceof Uint8Array ? b : new Uint8Array(b)); - -test('generate, encrypt with public key, and decrypt with private key', async () => { - const { publicKey, privateKey } = await generateRsaOaepKeyPair(); - - const plaintext = 'Hello RSA-OAEP'; - const data = encode(plaintext); - - const ciphertext = await encryptRsaOaep(publicKey, data); - expect(ciphertext).toBeInstanceOf(ArrayBuffer); - expect(new Uint8Array(ciphertext).byteLength).toBe(256); // 2048-bit modulus - - const decrypted = await decryptRsaOaep(privateKey, new Uint8Array(ciphertext)); - expect(decode(decrypted)).toBe(plaintext); - expect(new Uint8Array(decrypted).byteLength).toBe(data.byteLength); -}, 20000); - -test('export private key to JWK, import back, imported key is non-extractable and usable', async () => { - const { publicKey, privateKey } = await generateRsaOaepKeyPair(); - - const jwk = await exportRsaOaepKey(privateKey); - expect(jwk.kty).toBe('RSA'); - expect('d' in jwk).toBe(true); - - const importedPrivate = await importRsaOaepKey(jwk); - expect(importedPrivate.type).toBe('private'); - expect(importedPrivate.algorithm && (importedPrivate.algorithm as any).name).toBe('RSA-OAEP'); - - await expect(exportRsaOaepKey(importedPrivate)).rejects.toBeDefined(); - - const msg = 'Roundtrip after import'; - const ct = await encryptRsaOaep(publicKey, encode(msg)); - const pt = await decryptRsaOaep(importedPrivate, new Uint8Array(ct)); - expect(decode(pt)).toBe(msg); -}, 20000); - -test('importRsaOaepKey rejects when given a public JWK for decrypt usage', async () => { - const { publicKey } = await generateRsaOaepKeyPair(); - - const publicJwk = await exportRsaOaepKey(publicKey); - expect(publicJwk.kty).toBe('RSA'); - expect('d' in publicJwk).toBe(false); - - await expect(importRsaOaepKey(publicJwk)).rejects.toBeDefined(); -}, 10000); - -test('encrypt with private key rejects; decrypt with public key rejects', async () => { - const { publicKey, privateKey } = await generateRsaOaepKeyPair(); - - const data = encode('invalid ops'); - - await expect(encryptRsaOaep(privateKey, data)).rejects.toBeDefined(); - - const ct = await encryptRsaOaep(publicKey, data); - await expect(decryptRsaOaep(publicKey, new Uint8Array(ct))).rejects.toBeDefined(); -}, 15000); - -test('tampering with ciphertext causes decryption to fail', async () => { - const { publicKey, privateKey } = await generateRsaOaepKeyPair(); - - const data = encode('Do not tamper'); - - const ctBuf = await encryptRsaOaep(publicKey, data); - const tampered = new Uint8Array(ctBuf); - tampered[0]! ^= 0xff; - - await expect(decryptRsaOaep(privateKey, tampered)).rejects.toBeDefined(); -}, 15000); diff --git a/packages/e2ee/src/__tests__/vector.spec.ts b/packages/e2ee/src/__tests__/vector.spec.ts deleted file mode 100644 index 311ec734c0988..0000000000000 --- a/packages/e2ee/src/__tests__/vector.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect, test } from 'vitest'; -import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from '../vector.ts'; - -test('joinVectorAndEncryptedData and splitVectorAndEncryptedData', () => { - const [vector, encryptedData] = splitVectorAndEncryptedData( - joinVectorAndEncryptedData( - new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - new Uint8Array([13, 14, 15, 16]).buffer, - ), - ); - expect(vector).toMatchInlineSnapshot(` - Uint8Array [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - ] - `); - expect(encryptedData).toMatchInlineSnapshot(` - Uint8Array [ - 13, - 14, - 15, - 16, - ] - `); -}); - -test('joinVectorAndEncryptedData with invalid vector length', () => { - expect(() => - joinVectorAndEncryptedData(new Uint8Array([1, 2, 3]), new Uint8Array([17, 18, 19, 20]).buffer), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid vector length]`); -}); - -test('splitVectorAndEncryptedData with invalid cipherText length', () => { - expect(() => splitVectorAndEncryptedData(new Uint8Array([1, 2, 3]))).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid cipherText length]`, - ); -}); diff --git a/packages/e2ee/src/aes.ts b/packages/e2ee/src/aes.ts deleted file mode 100644 index 499fb3905b856..0000000000000 --- a/packages/e2ee/src/aes.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const encryptAesCbc = async ( - vector: Uint8Array, - key: CryptoKey, - data: Uint8Array, -): Promise => { - const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); - return encrypted; -}; - -export const decryptAesCbc = async ( - vector: Uint8Array, - key: CryptoKey, - data: Uint8Array, -): Promise => { - const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data); - return decrypted; -}; - -export const generateAesGcmKey = async (): Promise => { - const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - return key; -}; - -export const encryptAesGcm = async (iv: Uint8Array, key: CryptoKey, data: BufferSource): Promise => { - const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); - return encrypted; -}; - -export const decryptAesGcm = async (iv: Uint8Array, key: CryptoKey, data: Uint8Array): Promise => { - const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); - return decrypted; -}; diff --git a/packages/e2ee/src/base64.ts b/packages/e2ee/src/base64.ts deleted file mode 100644 index b218eea81efbd..0000000000000 --- a/packages/e2ee/src/base64.ts +++ /dev/null @@ -1,38 +0,0 @@ -const { fromBase64 } = globalThis.Uint8Array; -/* oxlint-disable-next-line typescript/unbound-method */ -const { toBase64 } = globalThis.Uint8Array.prototype; - -export const fromB64Fallback = (string: string): Uint8Array => { - const binary = atob(string); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -}; - -export const fromB64: (string: string) => Uint8Array = - /* v8 ignore next -- @preserve */ typeof fromBase64 === 'function' ? fromBase64 : fromB64Fallback; - -export const toB64Fallback = (input: Uint8Array): string => { - const CHUNK_SIZE = 0x80_00; - const arr = []; - for (let i = 0; i < input.length; i += CHUNK_SIZE) { - arr.push(String.fromCodePoint(...input.subarray(i, i + CHUNK_SIZE))); - } - return btoa(arr.join('')); -}; - -export const toB64: (input: Uint8Array) => string = - /* v8 ignore next -- @preserve */ - typeof toBase64 === 'function' ? (input) => toBase64.call(input) : toB64Fallback; - -export const parseUint8Array = (json: string): Uint8Array => { - const parsed = JSON.parse(json); - if (typeof parsed !== 'object' || parsed === null || !('$binary' in parsed) || typeof parsed.$binary !== 'string') { - throw new TypeError('Invalid JSON format, expected {"$binary":"..."}'); - } - return fromB64(parsed.$binary); -}; - -export const stringifyUint8Array = (data: Uint8Array): string => `{"$binary":"${toB64(data)}"}`; diff --git a/packages/e2ee/src/binary.ts b/packages/e2ee/src/binary.ts deleted file mode 100644 index d45b26af225c8..0000000000000 --- a/packages/e2ee/src/binary.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Returns the input unchanged if it's already a string; otherwise treats the input -// as an ArrayBuffer / TypedArray whose bytes become 1:1 char codes in the string. -export const toString = (data: ArrayBuffer): string => { - const u8 = new Uint8Array(data); - - // Convert in chunks to avoid exceeding argument length limits - const CHUNK = 0x80_00; - let result = ''; - for (let i = 0; i < u8.length; i += CHUNK) { - result += String.fromCodePoint(...u8.subarray(i, i + CHUNK)); - } - return result; -}; - -// Converts a "binary string" (each charCode 0–255) to an ArrayBuffer. -// Returns undefined if input is undefined; passes through existing ArrayBuffer; copies TypedArray/DataView. -export const toArrayBuffer = (data: string): Uint8Array => { - const BYTE_RANGE = 0x1_00; // 256 possible byte values - const INCREMENT = 1; - const len = data.length; - const u8 = new Uint8Array(len); - for (let i = 0; i < len; i += INCREMENT) { - // Mimic old ByteBuffer 'binary' behavior: low 8 bits only - const code = data.charCodeAt(i); - u8[i] = code % BYTE_RANGE; - } - return u8; -}; diff --git a/packages/e2ee/src/crypto.ts b/packages/e2ee/src/crypto.ts deleted file mode 100644 index 17860ac353374..0000000000000 --- a/packages/e2ee/src/crypto.ts +++ /dev/null @@ -1,190 +0,0 @@ -const RSA_ALGORITHM = { - name: 'RSA-OAEP', - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: 'SHA-256', -} as const satisfies RsaHashedKeyGenParams; - -const AES_ALGORITHM = { name: 'AES-GCM', length: 256 } as const satisfies AesKeyGenParams; - -const PBKDF2_PARAMS = { - name: 'PBKDF2', - hash: 'SHA-256', - iterations: 100_000, -} as const satisfies Omit; - -/** - * Encodes an ArrayBuffer into a Base64 string. - */ -const arrayBufferToBase64 = (buffer: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buffer))); - -/** - * Decodes a Base64 string into an ArrayBuffer. - */ -const base64ToArrayBuffer = (base64: string): Uint8Array => { - const binary_string = atob(base64); - const len = binary_string.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary_string.charCodeAt(i); - } - return bytes; -}; - -/** - * Generates a new RSA-OAEP key pair for asymmetric encryption. - */ -export const rsaGenerateKeyPair = async (): Promise => { - const keyPair = await crypto.subtle.generateKey(RSA_ALGORITHM, true, ['encrypt', 'decrypt']); - return keyPair; -}; - -/** - * Generates a new AES-GCM key for symmetric encryption. - */ -export const generateAesKey = async (): Promise => { - const key = await crypto.subtle.generateKey(AES_ALGORITHM, true, ['encrypt', 'decrypt']); - return key; -}; - -/** - * Derives an AES-GCM key from a passphrase using PBKDF2. - * @param passphrase The user's secret passphrase. - * @param salt A unique salt for the key derivation. - */ -export const deriveKeyFromPassphrase = async (passphrase: string, salt: Uint8Array): Promise => { - const masterKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); - - const key = await crypto.subtle.deriveKey({ ...PBKDF2_PARAMS, salt }, masterKey, { name: 'AES-GCM', length: 256 }, true, [ - 'encrypt', - 'decrypt', - ]); - - return key; -}; - -/** - * A bundle containing the user's encrypted private key and the necessary metadata for decryption. - * This should be stored securely on a server for account recovery. - */ -export interface EncryptedPrivateKeyBundle { - salt: string; // Base64 - iv: string; // Base64 - ciphertext: string; // Base64 - pbkdf2Iterations: number; - hashAlgorithm: 'SHA-256'; - encryptionAlgorithm: 'AES-GCM'; -} - -/** - * Decrypts a user's private key using their passphrase. - * @param bundle The bundle containing the encrypted key and metadata. - * @param passphrase The user's passphrase. - */ -export const recoverPrivateKeyFromBundle = async (bundle: EncryptedPrivateKeyBundle, passphrase: string): Promise => { - const salt = base64ToArrayBuffer(bundle.salt); - const iv = base64ToArrayBuffer(bundle.iv); - const ciphertext = base64ToArrayBuffer(bundle.ciphertext); - - const derivedKey = await deriveKeyFromPassphrase(passphrase, salt); - const decryptedKeyData = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derivedKey, ciphertext); - - const privateKey = await crypto.subtle.importKey('pkcs8', decryptedKeyData, RSA_ALGORITHM, true, ['decrypt']); - - return privateKey; -}; - -/** - * Encrypts data with an RSA public key. - * Used for wrapping the group's symmetric key. - */ -export const rsaEncrypt = async (groupKey: CryptoKey, publicKey: CryptoKey): Promise => { - const rawKey = await crypto.subtle.exportKey('raw', groupKey); - const encrypted = await crypto.subtle.encrypt(RSA_ALGORITHM, publicKey, rawKey); - return arrayBufferToBase64(encrypted); -}; - -/** - * Decrypts data with an RSA private key. - * Used for unwrapping the group's symmetric key. - */ -export const rsaDecrypt = async (encryptedKeyBase64: string, privateKey: CryptoKey): Promise => { - const encryptedKey = base64ToArrayBuffer(encryptedKeyBase64); - const decryptedRawKey = await crypto.subtle.decrypt(RSA_ALGORITHM, privateKey, encryptedKey); - const importedKey = await crypto.subtle.importKey('raw', decryptedRawKey, AES_ALGORITHM, true, ['encrypt', 'decrypt']); - return importedKey; -}; - -/** - * Represents the structure of an encrypted message ready for transmission. - */ -export interface EncryptedMessage { - ciphertext: string; // Base64 encoded - iv: string; // Base64 encoded -} - -/** - * Encrypts a message using a symmetric AES-GCM key. - */ -export const aesEncrypt = async (plaintext: string, key: CryptoKey): Promise => { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encodedPlaintext = new TextEncoder().encode(plaintext); - - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodedPlaintext); - - return { - ciphertext: arrayBufferToBase64(ciphertext), - iv: arrayBufferToBase64(iv.buffer), - }; -}; - -/** - * Decrypts a message using a symmetric AES-GCM key. - */ -export const aesDecrypt = async (encryptedMessage: EncryptedMessage, key: CryptoKey): Promise => { - const ciphertext = base64ToArrayBuffer(encryptedMessage.ciphertext); - const iv = base64ToArrayBuffer(encryptedMessage.iv); - - const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); - - return new TextDecoder().decode(decryptedBuffer); -}; - -export interface PublicKeyInfo { - format: 'jwk'; - keyData: JsonWebKey; - algorithm: RsaHashedImportParams; -} - -export const importPublicKey = async (keyInfo: PublicKeyInfo): Promise => { - const publicKey = await crypto.subtle.importKey(keyInfo.format, keyInfo.keyData, keyInfo.algorithm, true, ['encrypt']); - return publicKey; -}; - -export const exportPublicKeyInfo = async (key: CryptoKey): Promise => { - const jwk = await crypto.subtle.exportKey('jwk', key); - return { - format: 'jwk', - keyData: jwk, - algorithm: { name: 'RSA-OAEP', hash: 'SHA-256' }, - }; -}; - -export const createEncryptedPrivateKeyBundle = async (privateKey: CryptoKey, passphrase: string): Promise => { - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - const derivedKey = await deriveKeyFromPassphrase(passphrase, salt); - const exportedPrivateKey = await crypto.subtle.exportKey('pkcs8', privateKey); - - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, derivedKey, exportedPrivateKey); - - return { - salt: arrayBufferToBase64(salt.buffer), - iv: arrayBufferToBase64(iv.buffer), - ciphertext: arrayBufferToBase64(ciphertext), - pbkdf2Iterations: PBKDF2_PARAMS.iterations, - hashAlgorithm: PBKDF2_PARAMS.hash, - encryptionAlgorithm: 'AES-GCM', - }; -}; diff --git a/packages/e2ee/src/group.ts b/packages/e2ee/src/group.ts deleted file mode 100644 index 28dee9e9243cb..0000000000000 --- a/packages/e2ee/src/group.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { - type EncryptedMessage, - type EncryptedPrivateKeyBundle, - type PublicKeyInfo, - aesDecrypt, - aesEncrypt, - createEncryptedPrivateKeyBundle, - exportPublicKeyInfo, - generateAesKey, - importPublicKey, - recoverPrivateKeyFromBundle, - rsaDecrypt, - rsaEncrypt, - rsaGenerateKeyPair, -} from './crypto'; -import { Keychain } from './keychain'; - -/** - * Represents an encrypted group key for a specific user. - */ -export interface EncryptedGroupKeyInfo { - userId: string; - encryptedKey: string; // Base64 - keyAlgorithm: RsaHashedImportParams; -} - -export interface ChatService { - registerUser(userId: string, publicKeyInfo: PublicKeyInfo, privateKeyBundle: EncryptedPrivateKeyBundle): Promise; - getUserData(userId: string): Promise<{ publicKeyInfo: PublicKeyInfo; privateKeyBundle: EncryptedPrivateKeyBundle } | undefined>; - findUserPublicKey(userId: string): Promise; - findUsersPublicKeys(userIds: string[]): Promise>; - createGroup(groupId: string, initialEncryptedKeys: EncryptedGroupKeyInfo[]): Promise; - addUserToGroup(groupId: string, newMemberKeyInfo: EncryptedGroupKeyInfo): Promise; - getGroupKeyForUser(groupId: string, userId: string): Promise; - postMessage(groupId: string, message: EncryptedMessage): Promise; - getMessages(groupId: string): Promise; -} - -export class EncryptionClient { - private userId: string; - private privateKey: CryptoKey | false; - private groupKeys: Map; - private keychain: Keychain; - private chatService: ChatService; - - constructor(userId: string, chatService: ChatService, keychain: Keychain) { - this.userId = userId; - this.privateKey = false; - this.groupKeys = new Map(); - this.chatService = chatService; - this.keychain = keychain; - } - - public static async initialize(userId: string, chatService: ChatService): Promise { - const keychain = await Keychain.init(userId); - const client = new EncryptionClient(userId, chatService, keychain); - return client; - } - - /** - * Registers a new user. Generates a key pair and an encrypted private key bundle for recovery. - * The public key and the encrypted bundle should be stored on a server. - * @param userId A unique identifier for the user. - * @param passphrase A secret passphrase for account recovery. - */ - public async register(passphrase: string): Promise { - if (!this.userId) { - throw new Error('User ID is not set.'); - } - const { publicKey, privateKey } = await rsaGenerateKeyPair(); - await this.keychain.savePrivateKey(privateKey); - const publicKeyInfo = await exportPublicKeyInfo(publicKey); - const privateKeyBundle = await createEncryptedPrivateKeyBundle(privateKey, passphrase); - await this.chatService.registerUser(this.userId, publicKeyInfo, privateKeyBundle); - this.privateKey = privateKey; - } - - /** - * Logs in a user. Tries to load the private key from local storage first. - * If not found, it uses the passphrase and the server-stored bundle to recover and store it. - * @param userId The user's unique identifier. - * @param passphrase The user's passphrase (only needed for recovery). - * @param privateKeyBundle The recovery bundle from the server (only needed for recovery). - */ - public async login(passphrase?: string): Promise { - let privateKey = await this.keychain.getPrivateKey(); - - if (!this.userId) { - throw new Error('User ID is not set.'); - } - - if (!privateKey) { - if (!passphrase) { - throw new Error('Private key not found locally. Passphrase is required for recovery.'); - } - const userData = await this.chatService.getUserData(this.userId); - if (!userData) { - throw new Error(`User ${this.userId} not found.`); - } - privateKey = await recoverPrivateKeyFromBundle(userData.privateKeyBundle, passphrase); - await this.keychain.savePrivateKey(privateKey); - } - this.privateKey = privateKey; - } - - /** - * Creates a new group, generates a group key, and distributes it to the initial members. - */ - public async createGroup(groupId: string, memberUserIds: string[]): Promise { - if (!this.privateKey || !this.userId) { - throw new Error('User is not logged in.'); - } - - const allMemberIds = [...new Set([this.userId, ...memberUserIds])]; - const groupKey = await generateAesKey(); - - const publicKeyInfos = await this.chatService.findUsersPublicKeys(allMemberIds); - - const encryptedKeyInfos: EncryptedGroupKeyInfo[] = await Promise.all( - allMemberIds.map(async (memberId) => { - const publicKeyInfo = publicKeyInfos.get(memberId); - if (!publicKeyInfo) { - throw new Error(`Could not find public key for user ${memberId}`); - } - const memberPublicKey = await importPublicKey(publicKeyInfo); - const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); - return { - userId: memberId, - encryptedKey: encryptedGroupKey, - keyAlgorithm: publicKeyInfo.algorithm, - }; - }), - ); - - await this.chatService.createGroup(groupId, encryptedKeyInfos); - this.groupKeys.set(groupId, groupKey); - } - - /** - * Joins a group by fetching and decrypting the group key. - */ - public async joinGroup(groupId: string): Promise { - if (!this.privateKey || !this.userId) { - throw new Error('User is not logged in.'); - } - - const encryptedKeyInfo = await this.chatService.getGroupKeyForUser(groupId, this.userId); - if (!encryptedKeyInfo) { - throw new Error(`No group key found for user ${this.userId} in group ${groupId}.`); - } - - const groupKey = await rsaDecrypt(encryptedKeyInfo.encryptedKey, this.privateKey); - this.groupKeys.set(groupId, groupKey); - } - - /** - * Adds a new user to an existing group. This can only be done by a current group member. - * @param groupId The ID of the group. - * @param newUserPublicKeyInfo The public key info of the new user to add. - */ - public async addUserToGroup(groupId: string, newUserId: string): Promise { - if (!this.privateKey) { - throw new Error('User is not logged in.'); - } - const groupKey = this.groupKeys.get(groupId); - if (!groupKey) { - throw new Error(`You are not a member of group ${groupId} or key is not loaded.`); - } - const publicKeyInfo = await this.chatService.findUserPublicKey(newUserId); - if (!publicKeyInfo) { - throw new Error(`Could not find public key for user ${newUserId}`); - } - const memberPublicKey = await importPublicKey(publicKeyInfo); - const encryptedGroupKey = await rsaEncrypt(groupKey, memberPublicKey); - await this.chatService.addUserToGroup(groupId, { - userId: newUserId, - encryptedKey: encryptedGroupKey, - keyAlgorithm: publicKeyInfo.algorithm, - }); - } - - /** - * Encrypts a message to be sent to a group. - * @param groupId The ID of the target group. - * @param plaintext The message to send. - */ - public async encryptMessage(groupId: string, plaintext: string): Promise { - const groupKey = this.groupKeys.get(groupId); - if (!groupKey) { - throw new Error(`Not a member of group ${groupId} or key not loaded.`); - } - const encryptedMessage = await aesEncrypt(plaintext, groupKey); - return encryptedMessage; - } - - /** - * Decrypts a message received from a group. - * @param groupId The ID of the source group. - * @param encryptedMessage The encrypted message object. - */ - public async decryptMessage(groupId: string, encryptedMessage: EncryptedMessage): Promise { - const groupKey = this.groupKeys.get(groupId); - if (!groupKey) { - throw new Error(`Not a member of group ${groupId} or key not loaded.`); - } - const decryptedMessage = await aesDecrypt(encryptedMessage, groupKey); - return decryptedMessage; - } - - /** - * Logs out the current user by clearing all sensitive session data from memory. - * This includes the private key and any loaded group keys. - * Note: This does NOT delete the private key from local device storage (IndexedDB) - * to allow for easier login next time. - */ - public logout(): void { - this.userId = ''; - this.privateKey = false; - this.groupKeys.clear(); - } - - /** - * Performs a destructive logout, permanently deleting the user's private key - * from the local device storage (IndexedDB) and clearing all session data. - * This is intended for use on shared or untrusted devices. The user will need - * their passphrase and recovery bundle to log in on this device again. - */ - public async deleteAccountFromDevice(): Promise { - if (!this.userId) { - // If not logged in, there's nothing to delete. - console.warn('Attempted to delete account from device, but no user is logged in.'); - return; - } - - await this.keychain.deletePrivateKey(); - this.logout(); - } - - /** - * Encrypts a message and sends it to the server. - */ - public async sendMessage(groupId: string, message: string): Promise { - const groupKey = this.groupKeys.get(groupId); - if (!groupKey) { - throw new Error(`Not a member of group ${groupId} or key not loaded.`); - } - const encryptedMessage = await aesEncrypt(message, groupKey); - await this.chatService.postMessage(groupId, encryptedMessage); - } - - /** - * Fetches encrypted messages from the server and decrypts them. - */ - public async getMessages(groupId: string): Promise { - const groupKey = this.groupKeys.get(groupId); - if (!groupKey) { - throw new Error(`Not a member of group ${groupId} or key not loaded.`); - } - - const encryptedMessages = await this.chatService.getMessages(groupId); - - // Decrypt all messages in parallel - const decryptedMessages = await Promise.all( - encryptedMessages.map(async (msg) => { - const decryptedMessage = await aesDecrypt(msg, groupKey); - return decryptedMessage; - }), - ); - - return decryptedMessages; - } -} diff --git a/packages/e2ee/src/index.ts b/packages/e2ee/src/index.ts deleted file mode 100644 index eaeb20a0504eb..0000000000000 --- a/packages/e2ee/src/index.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { type AsyncResult, err, ok } from './result.ts'; -import { joinVectorAndEncryptedData, splitVectorAndEncryptedData } from './vector.ts'; -import { toArrayBuffer, toString } from './binary.ts'; - -export * as Jwk from './jwk.ts'; -import * as Jwk from './jwk.ts'; - -import { stringifyUint8Array, parseUint8Array } from './base64.ts'; -import { importPbkdf2Key, derivePbkdf2Key } from './pbkdf2.ts'; -import { generateRsaOaepKeyPair, importRsaOaepKey } from './rsa.ts'; -import { /** decryptAesCbc, encryptAesCbc, **/ encryptAesGcm, decryptAesGcm } from './aes.ts'; -// import { Keychain } from './keychain.ts'; - -export const generateMnemonicPhrase = async (length: number): Promise => { - const { wordlist } = await import('./wordlists/v2.ts'); - const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); - return Array.from(randomBuffer, (value) => wordlist[value % wordlist.length]).join(' '); -}; - -export interface KeyService { - userId: () => string | null; - fetchMyKeys: () => Promise; - persistKeys: (keys: RemoteKeyPair, force: boolean) => Promise; -} - -export type PrivateKey = string; -export type PublicKey = string; - -export interface RemoteKeyPair { - public_key: string; - private_key: string; -} - -export interface LocalKeyPair { - public_key: string | null | undefined; - private_key: string | null | undefined; -} - -export interface EncryptedKeyPair { - private_key: ArrayBuffer; - public_key: string; -} - -export interface KeyPair { - privateKey: CryptoKey; - publicKey: JsonWebKey; -} - -export default class E2EE { - #service: KeyService; - - constructor(service: KeyService) { - this.#service = service; - } - - async loadKeys(keys: EncryptedKeyPair): AsyncResult { - try { - const privKey = toString(keys.private_key); - const privKeyJwk = Jwk.parse(privKey); - if (!Jwk.isRsaOaep(privKeyJwk)) { - return err(new TypeError('Private key is not a valid RSA-OAEP JWK')); - } - const res = await importRsaOaepKey(privKeyJwk); - localStorage.setItem('public_key', keys.public_key); - localStorage.setItem('private_key', privKey); - return ok(res); - } catch (error) { - return err(new Error('Error loading keys', { cause: error })); - } - } - - async createAndLoadKeys(): AsyncResult { - try { - const keys = await generateRsaOaepKeyPair(); - try { - const publicKey = await this.setPublicKey(keys.publicKey); - try { - await this.setPrivateKey(keys.privateKey); - return ok({ - privateKey: keys.privateKey, - publicKey, - }); - } catch (error) { - return err(new Error('Error setting private key', { cause: error })); - } - } catch (error) { - return err(new Error('Error setting public key', { cause: error })); - } - } catch (error) { - return err(new Error('Error creating and loading keys', { cause: error })); - } - } - - async backupKeys(): Promise { - const res = await this.persistKeys(await this.createRandomPassword(5), false); - if (!res.isOk) { - throw res.error; - } - return res.value; - } - - async changePassphrase(newPassword: string): Promise { - const result = await this.persistKeys(newPassword, true); - - if (!result.isOk) { - throw result.error; - } - - if (this.getRandomPassword()) { - this.storeRandomPassword(newPassword); - } - } - - private async persistKeys(password: string, force: boolean): AsyncResult { - const localKeyPair = this.getKeysFromLocalStorage(); - - if (typeof localKeyPair.public_key !== 'string' || typeof localKeyPair.private_key !== 'string') { - return err(new TypeError('Failed to persist keys as they are not strings.')); - } - - const encodedPrivateKey = await this.encodePrivateKey(localKeyPair.private_key, password); - if (!encodedPrivateKey.isOk) { - return encodedPrivateKey; - } - - return ok( - await this.#service.persistKeys( - { - private_key: encodedPrivateKey.value, - public_key: localKeyPair.public_key, - }, - force, - ), - ); - } - - async encodePrivateKey(privateKey: string, password: string): AsyncResult { - const masterKey = await this.getMasterKey(password); - if (!masterKey.isOk) { - return masterKey; - } - const IV_LENGTH = 12; - const vector = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); - try { - const privateKeyBuffer = toArrayBuffer(privateKey); - const encryptedPrivateKey = await encryptAesGcm(vector, masterKey.value, privateKeyBuffer); - const joined = joinVectorAndEncryptedData(vector, encryptedPrivateKey); - const stringified = stringifyUint8Array(joined); - return ok(stringified); - } catch (error) { - return err(new Error('Error encrypting encodedPrivateKey', { cause: error })); - } - } - - async decodePrivateKey(privateKey: string, password: string): AsyncResult { - const masterKey = await this.getMasterKey(password); - if (!masterKey.isOk) { - return masterKey; - } - - const [vector, cipherText] = splitVectorAndEncryptedData(parseUint8Array(privateKey)); - - try { - const privKey = await decryptAesGcm(vector, masterKey.value, cipherText); - return ok(privKey); - } catch (error) { - return err(new Error('Error decrypting private key', { cause: error })); - } - } - - async loadKeysFromDB(): AsyncResult { - try { - const keys = await this.#service.fetchMyKeys(); - return ok(keys); - } catch (error) { - return err(new Error('Error loading keys from service', { cause: error })); - } - } - - /// Low-level key management methods - - /** - * Sets the public key in the storage. - * @param key The public key to store. - */ - async setPublicKey(key: CryptoKey): Promise { - const exported = await crypto.subtle.exportKey('jwk', key); - const stringified = JSON.stringify(exported); - localStorage.setItem('public_key', stringified); - return exported; - } - - async setPrivateKey(key: CryptoKey): Promise { - const exported = await crypto.subtle.exportKey('jwk', key); - const stringified = JSON.stringify(exported); - localStorage.setItem('private_key', stringified); - } - - async getMasterKey(password: string): AsyncResult { - // First, create a PBKDF2 "key" containing the password - const baseKey = await (async () => { - try { - return ok(await importPbkdf2Key(password)); - } catch (error) { - return err(new Error('Error creating a key based on user password', { cause: error })); - } - })(); - - if (!baseKey.isOk) { - return baseKey; - } - - const userId = this.#service.userId(); - - if (!userId) { - return err(new Error('User not found')); - } - - // Derive a key from the password - try { - return ok(await derivePbkdf2Key(userId, baseKey.value)); - } catch (error) { - return err(new Error('Error deriving baseKey', { cause: error })); - } - } - - getKeysFromLocalStorage(): LocalKeyPair { - const public_key = localStorage.getItem('public_key'); - const private_key = localStorage.getItem('private_key'); - return { - private_key, - public_key, - }; - } - - removeKeysFromLocalStorage(): void { - localStorage.removeItem('public_key'); - localStorage.removeItem('private_key'); - } - - storeRandomPassword(randomPassword: string): void { - localStorage.setItem('e2e.randomPassword', randomPassword); - } - - getRandomPassword(): string | null { - return localStorage.getItem('e2e.randomPassword'); - } - - removeRandomPassword(): void { - localStorage.removeItem('e2e.randomPassword'); - } - async createRandomPassword(length: number): Promise { - const randomPassword = await generateMnemonicPhrase(length); - this.storeRandomPassword(randomPassword); - return randomPassword; - } -} diff --git a/packages/e2ee/src/jwk.ts b/packages/e2ee/src/jwk.ts deleted file mode 100644 index f0165992c522c..0000000000000 --- a/packages/e2ee/src/jwk.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface Jwk { - kty: Kty; - alg: Alg; -} - -export const is = (data: unknown): data is Jwk => { - if (typeof data !== 'object') { - return false; - } - - if (data === null) { - return false; - } - - if (!('kty' in data)) { - return false; - } - - if (typeof data.kty !== 'string') { - return false; - } - - switch (data.kty) { - case 'oct': { - // Symmetric key: must have "k" - return 'k' in data && typeof data.k === 'string'; - } - case 'RSA': { - // RSA public/private key: must have "n" and "e" - return 'n' in data && typeof data.n === 'string' && 'e' in data && typeof data.e === 'string'; - } - case 'EC': { - // EC public/private key: must have "crv", "x", "y" - return ( - 'crv' in data && - typeof data.crv === 'string' && - 'x' in data && - typeof data.x === 'string' && - 'y' in data && - typeof data.y === 'string' - ); - } - case 'OKP': { - // OKP (e.g., Ed25519): must have "crv" and "x" - return 'crv' in data && typeof data.crv === 'string' && 'x' in data && typeof data.x === 'string'; - } - default: { - return false; - } - } -}; - -const isKey = - (kty: Kty, alg: Alg): ((jwk: Partial) => jwk is Jwk) => - (jwk): jwk is Jwk => - jwk.kty === kty && jwk.alg === alg; - -export const isAesGcm = (jwk: Partial): jwk is Jwk<'oct', 'A256GCM'> => isKey('oct', 'A256GCM')(jwk); -export const isAesCbc = (jwk: Partial): jwk is Jwk<'oct', 'A128CBC'> => isKey('oct', 'A128CBC')(jwk); - -export const isRsaOaep = (jwk: Partial): jwk is Jwk<'RSA', 'RSA-OAEP-256'> => isKey('RSA', 'RSA-OAEP-256')(jwk); - -export const parse = (json: string): Jwk => { - const obj = JSON.parse(json); - if (!is(obj)) { - throw new TypeError('Invalid JSON Web Key'); - } - return obj; -}; diff --git a/packages/e2ee/src/keychain.ts b/packages/e2ee/src/keychain.ts deleted file mode 100644 index cadc6d2fe0071..0000000000000 --- a/packages/e2ee/src/keychain.ts +++ /dev/null @@ -1,96 +0,0 @@ -export interface KeychainState { - userId: string; - db: IDBDatabase; -} - -export class Keychain { - private state: KeychainState; - - constructor(state: KeychainState) { - this.state = state; - } - - public static async init(userId: string): Promise { - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(`E2eeChatDB`, 1); - request.onerror = (): void => { - reject(request.error ?? new Error('Unknown error')); - }; - request.onsuccess = (): void => { - resolve(request.result); - }; - request.onupgradeneeded = (): void => { - const db = request.result; - if (!db.objectStoreNames.contains('CryptoKeys')) { - db.createObjectStore('CryptoKeys', { keyPath: 'userId' }); - } - }; - }); - - return new Keychain({ userId, db }); - } - - private getState(): KeychainState { - if (!this.state) { - throw new Error('Keychain is not initialized.'); - } - return this.state; - } - - public async savePrivateKey(privateKey: CryptoKey): Promise { - const { db, userId } = this.getState(); - - const res = await new Promise((resolve, reject) => { - const transaction = db.transaction('CryptoKeys', 'readwrite'); - const store = transaction.objectStore('CryptoKeys'); - const request = store.put({ userId, privateKey }); - request.onsuccess = (): void => { - resolve(true); - }; - request.onerror = (): void => { - reject(new Error('Failed to save private key', { cause: request.error })); - }; - }); - - return res; - } - - public async getPrivateKey(): Promise { - const { db, userId } = this.getState(); - - const privateKey = await new Promise((resolve, reject) => { - const transaction = db.transaction('CryptoKeys', 'readonly'); - const store = transaction.objectStore('CryptoKeys'); - const request = store.get(userId); - request.onsuccess = (): void => { - const result = request.result as unknown; - if (typeof result === 'object' && result !== null && 'privateKey' in result && result.privateKey instanceof CryptoKey) { - resolve(result.privateKey); - } else { - resolve(false); - } - }; - request.onerror = (): void => { - reject(new Error('Failed to get private key', { cause: request.error })); - }; - }); - - return privateKey; - } - - public async deletePrivateKey(): Promise { - const { db, userId } = this.getState(); - - await new Promise((resolve, reject) => { - const transaction = db.transaction('CryptoKeys', 'readwrite'); - const store = transaction.objectStore('CryptoKeys'); - const request = store.delete(userId); - request.onsuccess = (): void => { - resolve(); - }; - request.onerror = (): void => { - reject(new Error('Failed to delete private key', { cause: request.error })); - }; - }); - } -} diff --git a/packages/e2ee/src/pbkdf2.ts b/packages/e2ee/src/pbkdf2.ts deleted file mode 100644 index fcd24d7b203e9..0000000000000 --- a/packages/e2ee/src/pbkdf2.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Derives a non-extractable 256-bit AES-CBC key using PBKDF2. - * - * Uses UTF-8 encoded salt, 100,000 iterations, and SHA-256 as the PRF. - * The resulting key is created with usages ['encrypt', 'decrypt'] and is not extractable. - * - * @param salt - UTF-8 string used as the PBKDF2 salt. Prefer a cryptographically random salt. - * @param baseKey - A PBKDF2-capable CryptoKey (e.g., from {@link importPbkdf2Key}). - * @returns A promise that resolves to the derived AES-CBC CryptoKey. - * @remarks Requires a Web Crypto SubtleCrypto implementation (e.g., in modern browsers). - * @see https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey - */ -export const derivePbkdf2Key = async (salt: string, baseKey: CryptoKey): Promise => { - const derivedKey = await crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: new TextEncoder().encode(salt), - iterations: 100_000, - hash: 'SHA-256', - }, - baseKey, - { - name: 'AES-GCM', - length: 256, - }, - false, - ['encrypt', 'decrypt'], - ); - - return derivedKey; -}; - -/** - * Imports a passphrase as a non-extractable PBKDF2 base {@link CryptoKey}. - * - * The passphrase is UTF-8 encoded and the resulting key can only be used for 'deriveKey'. - * - * @param passphrase - Human-readable secret to be used as PBKDF2 input. - * @returns A promise that resolves to a PBKDF2 CryptoKey with usage ['deriveKey']. - * @remarks Pair this with {@link derivePbkdf2Key} to derive an encryption key. - * @see https://w3c.github.io/webcrypto/#pbkdf2-operations-import-key - */ -export const importPbkdf2Key = async (passphrase: string): Promise => { - const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']); - return key; -}; diff --git a/packages/e2ee/src/result.ts b/packages/e2ee/src/result.ts deleted file mode 100644 index 3e940a6d33949..0000000000000 --- a/packages/e2ee/src/result.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Ok { - isOk: true; - value: V; -} - -export interface Err { - isOk?: undefined; // or isErr: true - error: E; -} - -export type Result = Ok | Err; -export type AsyncResult = Promise>; - -export const ok = (value: V): Ok => ({ isOk: true, value }); -export const err = (error: E): Err => ({ error }); diff --git a/packages/e2ee/src/rsa.ts b/packages/e2ee/src/rsa.ts deleted file mode 100644 index 1c6fdd1f99891..0000000000000 --- a/packages/e2ee/src/rsa.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const generateRsaOaepKeyPair = async (): Promise => { - const keyPair = await crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], - ); - return keyPair; -}; - -export const importRsaOaepKey = async (jwk: JsonWebKey): Promise => { - const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSA-OAEP', hash: { name: 'SHA-256' } }, false, ['decrypt']); - return key; -}; - -export const exportRsaOaepKey = async (key: CryptoKey): Promise => { - const jwk = await crypto.subtle.exportKey('jwk', key); - return jwk; -}; - -export const decryptRsaOaep = async (key: CryptoKey, data: Uint8Array): Promise => { - const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); - return decrypted; -}; - -export const encryptRsaOaep = async (key: CryptoKey, data: Uint8Array): Promise => { - const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); - return encrypted; -}; diff --git a/packages/e2ee/src/vector.ts b/packages/e2ee/src/vector.ts deleted file mode 100644 index 4a648c0bf3738..0000000000000 --- a/packages/e2ee/src/vector.ts +++ /dev/null @@ -1,23 +0,0 @@ -const ZERO = 0; -const IV_LENGTH = 12; - -export const joinVectorAndEncryptedData = (vector: Uint8Array, cipherText: ArrayBuffer): Uint8Array => { - if (vector.length !== IV_LENGTH) { - throw new Error('Invalid vector length'); - } - - const output = new Uint8Array(vector.length + cipherText.byteLength); - - output.set(vector); - output.set(new Uint8Array(cipherText), vector.length); - - return output; -}; - -export const splitVectorAndEncryptedData = (cipherText: Uint8Array): [Uint8Array, Uint8Array] => { - if (cipherText.length <= IV_LENGTH) { - throw new Error('Invalid cipherText length'); - } - - return [cipherText.slice(ZERO, IV_LENGTH), cipherText.slice(IV_LENGTH)]; -}; diff --git a/packages/e2ee/src/wordlists/v1.ts b/packages/e2ee/src/wordlists/v1.ts deleted file mode 100644 index abc53dd4bd3a0..0000000000000 --- a/packages/e2ee/src/wordlists/v1.ts +++ /dev/null @@ -1,1633 +0,0 @@ -export const wordlist: string[] = `acrobat -africa -alaska -albert -albino -album -alcohol -alex -alpha -amadeus -amanda -amazon -america -analog -animal -antenna -antonio -apollo -april -aroma -artist -aspirin -athlete -atlas -banana -bandit -banjo -bikini -bingo -bonus -camera -canada -carbon -casino -catalog -cinema -citizen -cobra -comet -compact -complex -context -credit -critic -crystal -culture -david -delta -dialog -diploma -doctor -domino -dragon -drama -extra -fabric -final -focus -forum -galaxy -gallery -global -harmony -hotel -humor -index -japan -kilo -lemon -liter -lotus -mango -melon -menu -meter -metro -mineral -model -music -object -piano -pirate -plastic -radio -report -signal -sport -studio -subject -super -tango -taxi -tempo -tennis -textile -tokyo -total -tourist -video -visa -academy -alfred -atlanta -atomic -barbara -bazaar -brother -budget -cabaret -cadet -candle -capsule -caviar -channel -chapter -circle -cobalt -comrade -condor -crimson -cyclone -darwin -declare -denver -desert -divide -dolby -domain -double -eagle -echo -eclipse -editor -educate -edward -effect -electra -emerald -emotion -empire -eternal -evening -exhibit -expand -explore -extreme -ferrari -forget -freedom -friday -fuji -galileo -genesis -gravity -habitat -hamlet -harlem -helium -holiday -hunter -ibiza -iceberg -imagine -infant -isotope -jackson -jamaica -jasmine -java -jessica -kitchen -lazarus -letter -license -lithium -loyal -lucky -magenta -manual -marble -maxwell -mayor -monarch -monday -money -morning -mother -mystery -native -nectar -nelson -network -nikita -nobel -nobody -nominal -norway -nothing -number -october -office -oliver -opinion -option -order -outside -package -pandora -panther -papa -pattern -pedro -pencil -people -phantom -philips -pioneer -pluto -podium -portal -potato -process -proxy -pupil -python -quality -quarter -quiet -rabbit -radical -radius -rainbow -ramirez -ravioli -raymond -respect -respond -result -resume -richard -river -roger -roman -rondo -sabrina -salary -salsa -sample -samuel -saturn -savage -scarlet -scorpio -sector -serpent -shampoo -sharon -silence -simple -society -sonar -sonata -soprano -sparta -spider -sponsor -abraham -action -active -actor -adam -address -admiral -adrian -agenda -agent -airline -airport -alabama -aladdin -alarm -algebra -alibi -alice -alien -almond -alpine -amber -amigo -ammonia -analyze -anatomy -angel -annual -answer -apple -archive -arctic -arena -arizona -armada -arnold -arsenal -arthur -asia -aspect -athena -audio -august -austria -avenue -average -axiom -aztec -bagel -baker -balance -ballad -ballet -bambino -bamboo -baron -basic -basket -battery -belgium -benefit -berlin -bermuda -bernard -bicycle -binary -biology -bishop -blitz -block -blonde -bonjour -boris -boston -bottle -boxer -brandy -bravo -brazil -bridge -british -bronze -brown -bruce -bruno -brush -burger -burma -cabinet -cactus -cafe -cairo -calypso -camel -campus -canal -cannon -canoe -cantina -canvas -canyon -capital -caramel -caravan -career -cargo -carlo -carol -carpet -cartel -cartoon -castle -castro -cecilia -cement -center -century -ceramic -chamber -chance -change -chaos -charlie -charm -charter -cheese -chef -chemist -cherry -chess -chicago -chicken -chief -china -cigar -circus -city -clara -classic -claudia -clean -client -climax -clinic -clock -club -cockpit -coconut -cola -collect -colombo -colony -color -combat -comedy -command -company -concert -connect -consul -contact -contour -control -convert -copy -corner -corona -correct -cosmos -couple -courage -cowboy -craft -crash -cricket -crown -cuba -dallas -dance -daniel -decade -decimal -degree -delete -deliver -delphi -deluxe -demand -demo -denmark -derby -design -detect -develop -diagram -diamond -diana -diego -diesel -diet -digital -dilemma -direct -disco -disney -distant -dollar -dolphin -donald -drink -driver -dublin -duet -dynamic -earth -east -ecology -economy -edgar -egypt -elastic -elegant -element -elite -elvis -email -empty -energy -engine -english -episode -equator -escape -escort -ethnic -europe -everest -evident -exact -example -exit -exotic -export -express -factor -falcon -family -fantasy -fashion -fiber -fiction -fidel -fiesta -figure -film -filter -finance -finish -finland -first -flag -flash -florida -flower -fluid -flute -folio -ford -forest -formal -formula -fortune -forward -fragile -france -frank -fresh -friend -frozen -future -gabriel -gamma -garage -garcia -garden -garlic -gemini -general -genetic -genius -germany -gloria -gold -golf -gondola -gong -good -gordon -gorilla -grand -granite -graph -green -group -guide -guitar -guru -hand -happy -harbor -harvard -havana -hawaii -helena -hello -henry -hilton -history -horizon -house -human -icon -idea -igloo -igor -image -impact -import -india -indigo -input -insect -instant -iris -italian -jacket -jacob -jaguar -janet -jargon -jazz -jeep -john -joker -jordan -judo -jumbo -june -jungle -junior -jupiter -karate -karma -kayak -kermit -king -koala -korea -labor -lady -lagoon -laptop -laser -latin -lava -lecture -left -legal -level -lexicon -liberal -libra -lily -limbo -limit -linda -linear -lion -liquid -little -llama -lobby -lobster -local -logic -logo -lola -london -lucas -lunar -machine -macro -madam -madonna -madrid -maestro -magic -magnet -magnum -mailbox -major -mama -mambo -manager -manila -marco -marina -market -mars -martin -marvin -mary -master -matrix -maximum -media -medical -mega -melody -memo -mental -mentor -mercury -message -metal -meteor -method -mexico -miami -micro -milk -million -minimum -minus -minute -miracle -mirage -miranda -mister -mixer -mobile -modem -modern -modular -moment -monaco -monica -monitor -mono -monster -montana -morgan -motel -motif -motor -mozart -multi -museum -mustang -natural -neon -nepal -neptune -nerve -neutral -nevada -news -next -ninja -nirvana -normal -nova -novel -nuclear -numeric -nylon -oasis -observe -ocean -octopus -olivia -olympic -omega -opera -optic -optimal -orange -orbit -organic -orient -origin -orlando -oscar -oxford -oxygen -ozone -pablo -pacific -pagoda -palace -pamela -panama -pancake -panda -panel -panic -paradox -pardon -paris -parker -parking -parody -partner -passage -passive -pasta -pastel -patent -patient -patriot -patrol -pegasus -pelican -penguin -pepper -percent -perfect -perfume -period -permit -person -peru -phone -photo -picasso -picnic -picture -pigment -pilgrim -pilot -pixel -pizza -planet -plasma -plaza -pocket -poem -poetic -poker -polaris -police -politic -polo -polygon -pony -popcorn -popular -postage -precise -prefix -premium -present -price -prince -printer -prism -private -prize -product -profile -program -project -protect -proton -public -pulse -puma -pump -pyramid -queen -radar -ralph -random -rapid -rebel -record -recycle -reflex -reform -regard -regular -relax -reptile -reverse -ricardo -right -ringo -risk -ritual -robert -robot -rocket -rodeo -romeo -royal -russian -safari -salad -salami -salmon -salon -salute -samba -sandra -santana -sardine -school -scoop -scratch -screen -script -scroll -second -secret -section -segment -select -seminar -senator -senior -sensor -serial -service -shadow -sharp -sheriff -shock -short -shrink -sierra -silicon -silk -silver -similar -simon -single -siren -slang -slogan -smart -smoke -snake -social -soda -solar -solid -solo -sonic -source -soviet -special -speed -sphere -spiral -spirit -spring -static -status -stereo -stone -stop -street -strong -student -style -sultan -susan -sushi -suzuki -switch -symbol -system -tactic -tahiti -talent -tarzan -telex -texas -theory -thermos -tiger -titanic -tomato -topic -tornado -toronto -torpedo -totem -tractor -traffic -transit -trapeze -travel -tribal -trick -trident -trilogy -tripod -tropic -trumpet -tulip -tuna -turbo -twist -ultra -uniform -union -uranium -vacuum -valid -vampire -vanilla -vatican -velvet -ventura -venus -vertigo -veteran -victor -vienna -viking -village -vincent -violet -violin -virtual -virus -vision -visitor -visual -vitamin -viva -vocal -vodka -volcano -voltage -volume -voyage -water -weekend -welcome -western -window -winter -wizard -wolf -world -xray -yankee -yoga -yogurt -yoyo -zebra -zero -zigzag -zipper -zodiac -zoom -acid -adios -agatha -alamo -alert -almanac -aloha -andrea -anita -arcade -aurora -avalon -baby -baggage -balloon -bank -basil -begin -biscuit -blue -bombay -botanic -brain -brenda -brigade -cable -calibre -carmen -cello -celtic -chariot -chrome -citrus -civil -cloud -combine -common -cool -copper -coral -crater -cubic -cupid -cycle -depend -door -dream -dynasty -edison -edition -enigma -equal -eric -event -evita -exodus -extend -famous -farmer -food -fossil -frog -fruit -geneva -gentle -george -giant -gilbert -gossip -gram -greek -grille -hammer -harvest -hazard -heaven -herbert -heroic -hexagon -husband -immune -inca -inch -initial -isabel -ivory -jason -jerome -joel -joshua -journal -judge -juliet -jump -justice -kimono -kinetic -leonid -leopard -lima -maze -medusa -member -memphis -michael -miguel -milan -mile -miller -mimic -mimosa -mission -monkey -moral -moses -mouse -nancy -natasha -nebula -nickel -nina -noise -orchid -oregano -origami -orinoco -orion -othello -paper -paprika -prelude -prepare -pretend -promise -prosper -provide -puzzle -remote -repair -reply -rival -riviera -robin -rose -rover -rudolf -saga -sahara -scholar -shelter -ship -shoe -sigma -sister -sleep -smile -spain -spark -split -spray -square -stadium -star -storm -story -strange -stretch -stuart -subway -sugar -sulfur -summer -survive -sweet -swim -table -taboo -target -teacher -telecom -temple -tibet -ticket -tina -today -toga -tommy -tower -trivial -tunnel -turtle -twin -uncle -unicorn -unique -update -valery -vega -version -voodoo -warning -william -wonder -year -yellow -young -absent -absorb -absurd -accent -alfonso -alias -ambient -anagram -andy -anvil -appear -apropos -archer -ariel -armor -arrow -austin -avatar -axis -baboon -bahama -bali -balsa -barcode -bazooka -beach -beast -beatles -beauty -before -benny -betty -between -beyond -billy -bison -blast -bless -bogart -bonanza -book -border -brave -bread -break -broken -bucket -buenos -buffalo -bundle -button -buzzer -byte -caesar -camilla -canary -candid -carrot -cave -chant -child -choice -chris -cipher -clarion -clark -clever -cliff -clone -conan -conduct -congo -costume -cotton -cover -crack -current -danube -data -decide -deposit -desire -detail -dexter -dinner -donor -druid -drum -easy -eddie -enjoy -enrico -epoxy -erosion -except -exile -explain -fame -fast -father -felix -field -fiona -fire -fish -flame -flex -flipper -float -flood -floor -forbid -forever -fractal -frame -freddie -front -fuel -gallop -game -garbo -gate -gelatin -gibson -ginger -giraffe -gizmo -glass -goblin -gopher -grace -gray -gregory -grid -griffin -ground -guest -gustav -gyro -hair -halt -harris -heart -heavy -herman -hippie -hobby -honey -hope -horse -hostel -hydro -imitate -info -ingrid -inside -invent -invest -invite -ivan -james -jester -jimmy -join -joseph -juice -julius -july -kansas -karl -kevin -kiwi -ladder -lake -laura -learn -legacy -legend -lesson -life -light -list -locate -lopez -lorenzo -love -lunch -malta -mammal -margin -margo -marion -mask -match -mayday -meaning -mercy -middle -mike -mirror -modest -morph -morris -mystic -nadia -nato -navy -needle -neuron -never -newton -nice -night -nissan -nitro -nixon -north -oberon -octavia -ohio -olga -open -opus -orca -oval -owner -page -paint -palma -parent -parlor -parole -paul -peace -pearl -perform -phoenix -phrase -pierre -pinball -place -plate -plato -plume -pogo -point -polka -poncho -powder -prague -press -presto -pretty -prime -promo -quest -quick -quiz -quota -race -rachel -raja -ranger -region -remark -rent -reward -rhino -ribbon -rider -road -rodent -round -rubber -ruby -rufus -sabine -saddle -sailor -saint -salt -scale -scuba -season -secure -shake -shallow -shannon -shave -shelf -sherman -shine -shirt -side -sinatra -sincere -size -slalom -slow -small -snow -sofia -song -sound -south -speech -spell -spend -spoon -stage -stamp -stand -state -stella -stick -sting -stock -store -sunday -sunset -support -supreme -sweden -swing -tape -tavern -think -thomas -tictac -time -toast -tobacco -tonight -torch -torso -touch -toyota -trade -tribune -trinity -triton -truck -trust -type -under -unit -urban -urgent -user -value -vendor -venice -verona -vibrate -virgo -visible -vista -vital -voice -vortex -waiter -watch -wave -weather -wedding -wheel -whiskey -wisdom -android -annex -armani -cake -confide -deal -define -dispute -genuine -idiom -impress -include -ironic -null -nurse -obscure -prefer -prodigy -ego -fax -jet -job -rio -ski -yes`.split('\n'); diff --git a/packages/e2ee/src/wordlists/v2.ts b/packages/e2ee/src/wordlists/v2.ts deleted file mode 100644 index 2cfea1f1e8457..0000000000000 --- a/packages/e2ee/src/wordlists/v2.ts +++ /dev/null @@ -1,2048 +0,0 @@ -export const wordlist: string[] = `abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo`.split('\n'); diff --git a/packages/e2ee/tsconfig.build.json b/packages/e2ee/tsconfig.build.json deleted file mode 100644 index 653800a4672dd..0000000000000 --- a/packages/e2ee/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false - }, - "exclude": ["src/__tests__"] -} diff --git a/packages/e2ee/tsconfig.json b/packages/e2ee/tsconfig.json deleted file mode 100644 index 89a69fa4adb1e..0000000000000 --- a/packages/e2ee/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "compilerOptions": { - "composite": false, - "module": "preserve", - "target": "ES2024", - "lib": ["ESNext", "DOM"], - "types": [], - - /** Modules */ - "rootDir": "src", - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - - /** Emit */ - "noEmit": true, - "outDir": "dist", - "declaration": true, - "declarationMap": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - - /** Language and Environment */ - "libReplacement": false, - - /** Linting */ - "strict": true, - "erasableSyntaxOnly": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noPropertyAccessFromIndexSignature": true, - "noUncheckedIndexedAccess": true, - "noUncheckedSideEffectImports": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "isolatedModules": true, - "isolatedDeclarations": true, - "allowJs": false, - "esModuleInterop": false, - "skipLibCheck": false - }, - "include": ["src", "globals.d.ts"], - "exclude": [] -} diff --git a/packages/e2ee/turbo.json b/packages/e2ee/turbo.json deleted file mode 100644 index 45753e0a3055f..0000000000000 --- a/packages/e2ee/turbo.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://turborepo.org/schema.json", - "extends": ["//"], - "tasks": { - "testunit": { - "dependsOn": [], - "outputs": ["coverage/**"] - } - } -} \ No newline at end of file diff --git a/packages/e2ee/vitest.config.ts b/packages/e2ee/vitest.config.ts deleted file mode 100644 index a327737960fe8..0000000000000 --- a/packages/e2ee/vitest.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import { playwright } from '@vitest/browser/providers/playwright'; -export default defineConfig({ - test: { - includeTaskLocation: true, - coverage: { - provider: 'v8', - include: ['src/*.ts'], - exclude: ['src/__tests__/**'], - }, - browser: { - screenshotFailures: false, - headless: true, - enabled: true, - provider: playwright(), - isolate: false, - // https://vitest.dev/guide/browser/playwright - instances: [ - { browser: 'chromium' }, - // { browser: 'firefox' }, - // { browser: 'webkit' } - ], - }, - }, -}); From ca0665e0a956389d51633a59c96bd5dd0924b7d1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 12 Sep 2025 13:28:11 -0300 Subject: [PATCH 153/251] clean up --- .gitignore | 6 ---- playwright/fixtures.ts | 70 ------------------------------------------ tests/example.spec.ts | 14 --------- 3 files changed, 90 deletions(-) delete mode 100644 playwright/fixtures.ts delete mode 100644 tests/example.spec.ts diff --git a/.gitignore b/.gitignore index 2d0c98768cc0d..8741b33f36c35 100644 --- a/.gitignore +++ b/.gitignore @@ -59,9 +59,3 @@ registration.yaml storybook-static development/tempo-data/ - -# Playwright -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts deleted file mode 100644 index 69bbd9e71e918..0000000000000 --- a/playwright/fixtures.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { test as baseTest } from '@playwright/test'; -import fs from 'node:fs'; -import path from 'node:path'; - -export * from '@playwright/test'; - -const defaultAccount = { - username: 'jon.doe', - password: 'password123*R', -}; - -export const test = baseTest.extend<{}, { account: { username: string; password: string }; workerStorageState: string }>({ - // Use the same storage state for all tests in this worker. - storageState: ({ workerStorageState }, use) => use(workerStorageState), - account: [({}, use) => use(defaultAccount), { scope: 'worker' }], - - // Authenticate once per worker with a worker-scoped fixture. - workerStorageState: [ - async ({ browser, account }, use) => { - // Use parallelIndex as a unique identifier for each worker. - const { parallelIndex } = test.info(); - - const fileName = path.resolve(test.info().project.outputDir, `.auth/${account.username}/${parallelIndex}.json`); - - if (fs.existsSync(fileName)) { - // Reuse existing authentication state if any. - await use(fileName); - return; - } - - // Important: make sure we authenticate in a clean environment by unsetting storage state. - const page = await browser.newPage({ storageState: undefined }); - - // Acquire a unique account, for example create a new one. - // Alternatively, you can have a list of precreated accounts for testing. - // Make sure that accounts are unique, so that multiple team members - // can run tests at the same time without interference. - - // await page.goto('http://localhost:3000/'); - // await page.getByRole('link', { name: 'Create an account' }).click(); - // await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Jon Doe'); - // await page.getByRole('textbox', { name: 'Email' }).fill('jon@doe.com'); - // await page.getByRole('textbox', { name: 'Username' }).fill('jon.doe'); - // await page.getByRole('textbox', { name: 'Password', exact: true }).fill('password123*R'); - // await page.getByRole('textbox', { name: 'Confirm your password' }).fill('password123*R'); - // await page.getByRole('button', { name: 'Join your team' }).click(); - - // Perform authentication steps. Replace these actions with your own. - await page.goto('http://localhost:3000/login'); - await page.getByRole('textbox', { name: 'Email or username' }).fill(account.username); - await page.getByRole('textbox', { name: 'Password' }).fill(account.password); - await page.getByRole('button', { name: 'Login' }).click(); - // Wait until the page receives the cookies. - // - // Sometimes login flow sets cookies in the process of several redirects. - // Wait for the final URL to ensure that the cookies are actually set. - await page.waitForURL('http://localhost:3000/home'); - await page.waitForFunction(() => localStorage.getItem('Meteor.loginToken') !== null); - // Alternatively, you can wait until the page reaches a state where all cookies are set. - // await expect(page).toHaveTitle(/Login - Rocket.Chat/); - - // End of authentication steps. - - await page.context().storageState({ path: fileName, indexedDB: true }); - await page.close(); - await use(fileName); - }, - { scope: 'worker' }, - ], -}); diff --git a/tests/example.spec.ts b/tests/example.spec.ts deleted file mode 100644 index cb1ade4c1e256..0000000000000 --- a/tests/example.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { test, expect } from '../playwright/fixtures'; - -test.beforeEach(async ({ page }) => { - await page.goto('/home'); -}); - -test('end-to-end encryption', async ({ page }) => { - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Home - Rocket.Chat/); - // Create an end-to-end encrypted channel - await page.getByRole('button', { name: 'Create Channel' }).click(); - await page.getByRole('textbox', { name: 'Channel Name' }).fill('encrypted-channel'); - await page.getByRole('button', { name: 'Create' }).click(); -}); From 413305166857513af719e3e8fbaef7dee04db74e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 12 Sep 2025 13:31:01 -0300 Subject: [PATCH 154/251] remove separate package pt 2 --- apps/meteor/package.json | 1 - yarn.lock | 1066 +------------------------------------- 2 files changed, 10 insertions(+), 1057 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 9bed0cfb01843..30e6ef223a437 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -251,7 +251,6 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/cron": "workspace:^", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/e2ee": "workspace:^", "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/freeswitch": "workspace:^", diff --git a/yarn.lock b/yarn.lock index c64253b6d0914..ee1c1e57230d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -445,17 +445,6 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.25.4": - version: 7.28.3 - resolution: "@babel/parser@npm:7.28.3" - dependencies: - "@babel/types": "npm:^7.28.2" - bin: - parser: ./bin/babel-parser.js - checksum: 10/9fa08282e345b9d892a6757b2789a9a53a00f7b7b34d6254a4ee0bf32c5eb275919091ea96d6f136a948d5de9c8219235957d04a36ab7378a9d93a4cf0799155 - languageName: node - linkType: hard - "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9" @@ -1621,16 +1610,6 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.25.4, @babel/types@npm:^7.28.2": - version: 7.28.2 - resolution: "@babel/types@npm:7.28.2" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10/a8de404a2e3109651f346d892dc020ce2c82046068f4ce24de7f487738dfbfa7bd716b35f1dcd6d6c32dde96208dc74a56b7f56a2c0bcb5af0ddc56cbee13533 - languageName: node - linkType: hard - "@babel/types@npm:^7.28.0": version: 7.28.0 resolution: "@babel/types@npm:7.28.0" @@ -1648,13 +1627,6 @@ __metadata: languageName: node linkType: hard -"@bcoe/v8-coverage@npm:^1.0.2": - version: 1.0.2 - resolution: "@bcoe/v8-coverage@npm:1.0.2" - checksum: 10/46600b2dde460269b07a8e4f12b72e418eae1337b85c979f43af3336c9a1c65b04e42508ab6b245f1e0e3c64328e1c38d8cd733e4a7cebc4fbf9cf65c6e59937 - languageName: node - linkType: hard - "@blakek/curry@npm:^2.0.2": version: 2.0.2 resolution: "@blakek/curry@npm:2.0.2" @@ -3559,13 +3531,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.5.5": - version: 1.5.5 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" - checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 - languageName: node - linkType: hard - "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3596,16 +3561,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.30": - version: 0.3.30 - resolution: "@jridgewell/trace-mapping@npm:0.3.30" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10/f9fabe1122058f4fedc242bdc43fcaacb6b222c6fb712b7904c37704dcb16e50e07ca137ff99043da44292b18a8720296ff97f7703e960678b8ef51d0db4d250 - languageName: node - linkType: hard - "@js-sdsl/ordered-map@npm:^4.4.2": version: 4.4.2 resolution: "@js-sdsl/ordered-map@npm:4.4.2" @@ -4803,104 +4758,6 @@ __metadata: languageName: node linkType: hard -"@oxlint-tsgolint/darwin-arm64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.1.5" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/darwin-x64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/darwin-x64@npm:0.1.5" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-arm64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/linux-arm64@npm:0.1.5" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/linux-x64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/linux-x64@npm:0.1.5" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-arm64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/win32-arm64@npm:0.1.5" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint-tsgolint/win32-x64@npm:0.1.5": - version: 0.1.5 - resolution: "@oxlint-tsgolint/win32-x64@npm:0.1.5" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@oxlint/darwin-arm64@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/darwin-arm64@npm:1.14.0" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/darwin-x64@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/darwin-x64@npm:1.14.0" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@oxlint/linux-arm64-gnu@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/linux-arm64-gnu@npm:1.14.0" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/linux-arm64-musl@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/linux-arm64-musl@npm:1.14.0" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@oxlint/linux-x64-gnu@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/linux-x64-gnu@npm:1.14.0" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@oxlint/linux-x64-musl@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/linux-x64-musl@npm:1.14.0" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@oxlint/win32-arm64@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/win32-arm64@npm:1.14.0" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@oxlint/win32-x64@npm:1.14.0": - version: 1.14.0 - resolution: "@oxlint/win32-x64@npm:1.14.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@paralleldrive/cuid2@npm:^2.2.2": version: 2.2.2 resolution: "@paralleldrive/cuid2@npm:2.2.2" @@ -5097,13 +4954,6 @@ __metadata: languageName: node linkType: hard -"@polka/url@npm:^1.0.0-next.24": - version: 1.0.0-next.29 - resolution: "@polka/url@npm:1.0.0-next.29" - checksum: 10/69ca11ab15a4ffec7f0b07fcc4e1f01489b3d9683a7e1867758818386575c60c213401259ba3705b8a812228d17e2bfd18e6f021194d943fff4bca389c9d4f28 - languageName: node - linkType: hard - "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -7411,21 +7261,6 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/e2ee@workspace:^, @rocket.chat/e2ee@workspace:packages/e2ee": - version: 0.0.0-use.local - resolution: "@rocket.chat/e2ee@workspace:packages/e2ee" - dependencies: - "@vitest/browser": "npm:4.0.0-beta.10" - "@vitest/coverage-v8": "npm:4.0.0-beta.10" - "@vitest/ui": "npm:4.0.0-beta.10" - oxlint: "npm:~1.14.0" - oxlint-tsgolint: "npm:~0.1.5" - playwright: "npm:^1.55.0" - typescript: "npm:~5.9.2" - vitest: "npm:4.0.0-beta.10" - languageName: unknown - linkType: soft - "@rocket.chat/emitter@npm:~0.31.25": version: 0.31.25 resolution: "@rocket.chat/emitter@npm:0.31.25" @@ -8080,7 +7915,6 @@ __metadata: "@rocket.chat/cron": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/desktop-api": "workspace:~" - "@rocket.chat/e2ee": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" @@ -9609,13 +9443,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.46.2" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@rollup/rollup-android-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-android-arm64@npm:4.34.4" @@ -9623,13 +9450,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-android-arm64@npm:4.46.2" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-darwin-arm64@npm:4.34.4" @@ -9637,13 +9457,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-darwin-arm64@npm:4.46.2" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-x64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-darwin-x64@npm:4.34.4" @@ -9651,13 +9464,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-darwin-x64@npm:4.46.2" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-freebsd-arm64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-freebsd-arm64@npm:4.34.4" @@ -9665,13 +9471,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.46.2" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-freebsd-x64@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-freebsd-x64@npm:4.34.4" @@ -9679,13 +9478,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-freebsd-x64@npm:4.46.2" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4" @@ -9693,13 +9485,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.46.2" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-musleabihf@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.34.4" @@ -9707,13 +9492,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.46.2" - conditions: os=linux & cpu=arm & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.34.4" @@ -9721,13 +9499,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.46.2" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-musl@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.34.4" @@ -9735,13 +9506,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.46.2" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4" @@ -9749,13 +9513,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.46.2" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4" @@ -9763,13 +9520,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.46.2" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-riscv64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.34.4" @@ -9777,20 +9527,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.46.2" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-riscv64-musl@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.46.2" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-s390x-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.34.4" @@ -9798,13 +9534,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.46.2" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-gnu@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.34.4" @@ -9812,13 +9541,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.46.2" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-musl@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-linux-x64-musl@npm:4.34.4" @@ -9826,13 +9548,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.46.2" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-win32-arm64-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.34.4" @@ -9840,13 +9555,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.46.2" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-win32-ia32-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.34.4" @@ -9854,13 +9562,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.46.2" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@rollup/rollup-win32-x64-msvc@npm:4.34.4": version: 4.34.4 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.34.4" @@ -9868,13 +9569,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.46.2": - version: 4.46.2 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.46.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -10996,22 +10690,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^10.4.1": - version: 10.4.1 - resolution: "@testing-library/dom@npm:10.4.1" - dependencies: - "@babel/code-frame": "npm:^7.10.4" - "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.3.0" - dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.5.0" - picocolors: "npm:1.1.1" - pretty-format: "npm:^27.0.2" - checksum: 10/7f93e09ea015f151f8b8f42cbab0b2b858999b5445f15239a72a612ef7716e672b14c40c421218194cf191cbecbde0afa6f3dc2cc83dda93ff6a4fb0237df6e6 - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:6.5.0": version: 6.5.0 resolution: "@testing-library/jest-dom@npm:6.5.0" @@ -11091,15 +10769,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.6.1": - version: 14.6.1 - resolution: "@testing-library/user-event@npm:14.6.1" - peerDependencies: - "@testing-library/dom": ">=7.21.4" - checksum: 10/34b74fff56a0447731a94b40d4cf246deb8dbc1c1e3aec93acd1c3377a760bb062e979f1572bb34ec164ad28ee2a391744b42d0d6d6cc16c4ce527e5e09610e1 - languageName: node - linkType: hard - "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -11328,15 +10997,6 @@ __metadata: languageName: node linkType: hard -"@types/chai@npm:^5.2.2": - version: 5.2.2 - resolution: "@types/chai@npm:5.2.2" - dependencies: - "@types/deep-eql": "npm:*" - checksum: 10/de425e7b02cc1233a93923866e019dffbafa892774813940b780ebb1ac9f8a8c57b7438c78686bf4e5db05cd3fc8a970fedf6b83638543995ecca88ef2060668 - languageName: node - linkType: hard - "@types/chalk@npm:^2.2.4": version: 2.2.4 resolution: "@types/chalk@npm:2.2.4" @@ -11738,13 +11398,6 @@ __metadata: languageName: node linkType: hard -"@types/deep-eql@npm:*": - version: 4.0.2 - resolution: "@types/deep-eql@npm:4.0.2" - checksum: 10/249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c - languageName: node - linkType: hard - "@types/doctrine@npm:^0.0.9": version: 0.0.9 resolution: "@types/doctrine@npm:0.0.9" @@ -11802,13 +11455,6 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.8": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: 10/25a4c16a6752538ffde2826c2cc0c6491d90e69cd6187bef4a006dd2c3c45469f049e643d7e516c515f21484dc3d48fd5c870be158a5beb72f5baf3dc43e4099 - languageName: node - linkType: hard - "@types/express-rate-limit@npm:^5.1.3": version: 5.1.3 resolution: "@types/express-rate-limit@npm:5.1.3" @@ -13447,60 +13093,6 @@ __metadata: languageName: node linkType: hard -"@vitest/browser@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/browser@npm:4.0.0-beta.10" - dependencies: - "@testing-library/dom": "npm:^10.4.1" - "@testing-library/user-event": "npm:^14.6.1" - "@vitest/mocker": "npm:4.0.0-beta.10" - "@vitest/utils": "npm:4.0.0-beta.10" - magic-string: "npm:^0.30.18" - pixelmatch: "npm:7.1.0" - pngjs: "npm:^7.0.0" - sirv: "npm:^3.0.1" - tinyrainbow: "npm:^2.0.0" - ws: "npm:^8.18.3" - peerDependencies: - playwright: "*" - vitest: 4.0.0-beta.10 - webdriverio: ^7.0.0 || ^8.0.0 || ^9.0.0 - peerDependenciesMeta: - playwright: - optional: true - safaridriver: - optional: true - webdriverio: - optional: true - checksum: 10/49da585b7e5702003584efca16f10a85e2cf6e8f9352e4a012af0a819388cc6be633bb23b37f33d95aa01f86bafc349ce8d83ae37351e1386e9a5db5bac7edec - languageName: node - linkType: hard - -"@vitest/coverage-v8@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/coverage-v8@npm:4.0.0-beta.10" - dependencies: - "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.0-beta.10" - ast-v8-to-istanbul: "npm:^0.3.4" - debug: "npm:^4.4.1" - istanbul-lib-coverage: "npm:^3.2.2" - istanbul-lib-report: "npm:^3.0.1" - istanbul-lib-source-maps: "npm:^5.0.6" - istanbul-reports: "npm:^3.2.0" - magicast: "npm:^0.3.5" - std-env: "npm:^3.9.0" - tinyrainbow: "npm:^2.0.0" - peerDependencies: - "@vitest/browser": 4.0.0-beta.10 - vitest: 4.0.0-beta.10 - peerDependenciesMeta: - "@vitest/browser": - optional: true - checksum: 10/56556f73e3f34f7aa005d56715af2fec69c568f3e3be1fc827f5fac6e707159b0d5ef8b57197037a00b612e2f3841017e9b289190a2ad651afe9f9c56b8f7c72 - languageName: node - linkType: hard - "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -13513,38 +13105,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/expect@npm:4.0.0-beta.10" - dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.0-beta.10" - "@vitest/utils": "npm:4.0.0-beta.10" - chai: "npm:^6.0.1" - tinyrainbow: "npm:^2.0.0" - checksum: 10/8fd1af7c820096e185b7b060369fb25f59aa8a0152f58fbb815504e9c19cab031cbee392d5853e501086c7f172a4175117cccdcf65504860f3594a9b805dcdca - languageName: node - linkType: hard - -"@vitest/mocker@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/mocker@npm:4.0.0-beta.10" - dependencies: - "@vitest/spy": "npm:4.0.0-beta.10" - estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.18" - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - checksum: 10/a87e99016f4201ca5c73fadfe0036df269118f75b58182bcdbf7838501bb75cabb3d5617d33020d2409004cff727dedeaba555bde0d60fadb6b19faf971c2cb2 - languageName: node - linkType: hard - "@vitest/pretty-format@npm:2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" @@ -13563,37 +13123,6 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.0-beta.10, @vitest/pretty-format@npm:^4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/pretty-format@npm:4.0.0-beta.10" - dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10/522ba8a2c5d18d8fa853e3b611a62ad858340ef3506f09c8e864e44e9591dc2f6fcc9271593e9a77ff04a9991c86c9e1342aae153ac8df6fe51b7c6066d65ea8 - languageName: node - linkType: hard - -"@vitest/runner@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/runner@npm:4.0.0-beta.10" - dependencies: - "@vitest/utils": "npm:4.0.0-beta.10" - pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10/4bdca8daf6e148c7ce425d9e96bc24900489047078bd248bf3343c9df3da2360d2431e87314c106083aaae993f6e78bcee06b449cbbb782e0b63f6b06d565889 - languageName: node - linkType: hard - -"@vitest/snapshot@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/snapshot@npm:4.0.0-beta.10" - dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.10" - magic-string: "npm:^0.30.18" - pathe: "npm:^2.0.3" - checksum: 10/cb3476f01671b5ee1f5086dab1ddcc8b8e43534231c1c7ae55798b34108a6b93218d9fed1f6c347a021acb3978047574b9260719ef08e6725108b6f63136d9c6 - languageName: node - linkType: hard - "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -13603,30 +13132,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/spy@npm:4.0.0-beta.10" - checksum: 10/31db62bcc8fc51a951b44a488331289671f6d4a63d90c48a0812734ae36503c3479f793b0fced9d1d96764d2a6891e7c88d32b0716bef2353f3f0f7a52565da7 - languageName: node - linkType: hard - -"@vitest/ui@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/ui@npm:4.0.0-beta.10" - dependencies: - "@vitest/utils": "npm:4.0.0-beta.10" - fflate: "npm:^0.8.2" - flatted: "npm:^3.3.3" - pathe: "npm:^2.0.3" - sirv: "npm:^3.0.1" - tinyglobby: "npm:^0.2.14" - tinyrainbow: "npm:^2.0.0" - peerDependencies: - vitest: 4.0.0-beta.10 - checksum: 10/2cdc1d80bb40e93ecb36a5e798be8454c3ec82ca058ae05cfb08dd7f819329d5de5154a1e5c49b63857721604349fd95a31d62a927cf22d48b97ac2191f61e9c - languageName: node - linkType: hard - "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -13639,17 +13144,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "@vitest/utils@npm:4.0.0-beta.10" - dependencies: - "@vitest/pretty-format": "npm:4.0.0-beta.10" - loupe: "npm:^3.2.1" - tinyrainbow: "npm:^2.0.0" - checksum: 10/ea4b66adbbafdf239f878cbeb64cd7edc9fec5b90edaca1217355bad0aeb125176b729cfb8ac17be464849debc53c5bef5059cbe19a43ef0a8ced449de0c504d - languageName: node - linkType: hard - "@vitest/utils@npm:^2.1.1": version: 2.1.4 resolution: "@vitest/utils@npm:2.1.4" @@ -14724,17 +14218,6 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.4": - version: 0.3.5 - resolution: "ast-v8-to-istanbul@npm:0.3.5" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.30" - estree-walker: "npm:^3.0.3" - js-tokens: "npm:^9.0.1" - checksum: 10/5ba88ab050ea34b823808bc29784278a01c8af6d86321ad9e5a6c527eceac613aed23bee3490255e33ad30a081888c3a3bf36497fd24d4d69d81913e0a4e3567 - languageName: node - linkType: hard - "asterisk-manager@npm:^0.2.0": version: 0.2.0 resolution: "asterisk-manager@npm:0.2.0" @@ -16313,13 +15796,6 @@ __metadata: languageName: node linkType: hard -"chai@npm:^6.0.1": - version: 6.0.1 - resolution: "chai@npm:6.0.1" - checksum: 10/cd95aee5f98cdeaea3e24c21dcf1786d9eaf515d571861106a2378589cf5ef1ed3ddd223ca1121552656ffd744e4acd051f8881c1a9729c71551776db55fb2cb - languageName: node - linkType: hard - "chalk@npm:*, chalk@npm:^5.2.0": version: 5.4.1 resolution: "chalk@npm:5.4.1" @@ -18142,18 +17618,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.1": - version: 4.4.1 - resolution: "debug@npm:4.4.1" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe - languageName: node - linkType: hard - "debug@npm:~3.1.0": version: 3.1.0 resolution: "debug@npm:3.1.0" @@ -19508,13 +18972,6 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.7.0": - version: 1.7.0 - resolution: "es-module-lexer@npm:1.7.0" - checksum: 10/b6f3e576a3fed4d82b0d0ad4bbf6b3a5ad694d2e7ce8c4a069560da3db6399381eaba703616a182b16dde50ce998af64e07dcf49f2ae48153b9e07be3f107087 - languageName: node - linkType: hard - "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -20361,13 +19818,6 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.2": - version: 1.2.2 - resolution: "expect-type@npm:1.2.2" - checksum: 10/1703e6e47b575f79d801d87f24c639f4d0af71b327a822e6922d0ccb7eb3f6559abb240b8bd43bab6a477903de4cc322908e194d05132c18f52a217115e8e870 - languageName: node - linkType: hard - "expect@npm:30.0.5, expect@npm:^30.0.0": version: 30.0.5 resolution: "expect@npm:30.0.5" @@ -20668,18 +20118,6 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4, fdir@npm:^6.4.6": - version: 6.5.0 - resolution: "fdir@npm:6.5.0" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1 - languageName: node - linkType: hard - "fecha@npm:^4.2.0": version: 4.2.3 resolution: "fecha@npm:4.2.3" @@ -21077,13 +20515,6 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.3.3": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 10/8c96c02fbeadcf4e8ffd0fa24983241e27698b0781295622591fc13585e2f226609d95e422bcf2ef044146ffacb6b68b1f20871454eddf75ab3caa6ee5f4a1fe - languageName: node - linkType: hard - "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -23977,13 +23408,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-coverage@npm:^3.2.2": - version: 3.2.2 - resolution: "istanbul-lib-coverage@npm:3.2.2" - checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 - languageName: node - linkType: hard - "istanbul-lib-hook@npm:^3.0.0": version: 3.0.0 resolution: "istanbul-lib-hook@npm:3.0.0" @@ -24056,17 +23480,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-report@npm:^3.0.1": - version: 3.0.1 - resolution: "istanbul-lib-report@npm:3.0.1" - dependencies: - istanbul-lib-coverage: "npm:^3.0.0" - make-dir: "npm:^4.0.0" - supports-color: "npm:^7.1.0" - checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 - languageName: node - linkType: hard - "istanbul-lib-source-maps@npm:^4.0.0": version: 4.0.1 resolution: "istanbul-lib-source-maps@npm:4.0.1" @@ -24078,7 +23491,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.0, istanbul-lib-source-maps@npm:^5.0.6": +"istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" dependencies: @@ -24099,16 +23512,6 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.2.0": - version: 3.2.0 - resolution: "istanbul-reports@npm:3.2.0" - dependencies: - html-escaper: "npm:^2.0.0" - istanbul-lib-report: "npm:^3.0.0" - checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e - languageName: node - linkType: hard - "isurl@npm:^1.0.0-alpha5": version: 1.0.0 resolution: "isurl@npm:1.0.0" @@ -25348,13 +24751,6 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^9.0.1": - version: 9.0.1 - resolution: "js-tokens@npm:9.0.1" - checksum: 10/3288ba73bb2023adf59501979fb4890feb6669cc167b13771b226814fde96a1583de3989249880e3f4d674040d1815685db9a9880db9153307480d39dc760365 - languageName: node - linkType: hard - "js-yaml@npm:4.1.0, js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -26353,13 +25749,6 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.2.1": - version: 3.2.1 - resolution: "loupe@npm:3.2.1" - checksum: 10/a4d78ec758aaa04e0e35d5cd1c15e970beb9cdbfd3d0f34f98b9bcda489f896a7190b3b6cc40b7a6dcb8e97e82e96eafaae10096aaa469804acdba6f7c2bde5f - languageName: node - linkType: hard - "lowdb@npm:1": version: 1.0.0 resolution: "lowdb@npm:1.0.0" @@ -26453,15 +25842,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.18": - version: 0.30.18 - resolution: "magic-string@npm:0.30.18" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10/dd180f174cfe066f501dc700ec58815eae3cbde402308f3326574bad09a5ff9c9c5b6fc71502e9d1c663254614fe21679c6360cd39aaeedd2cee1b40e14669f2 - languageName: node - linkType: hard - "magic-string@npm:^0.30.5": version: 0.30.11 resolution: "magic-string@npm:0.30.11" @@ -26471,17 +25851,6 @@ __metadata: languageName: node linkType: hard -"magicast@npm:^0.3.5": - version: 0.3.5 - resolution: "magicast@npm:0.3.5" - dependencies: - "@babel/parser": "npm:^7.25.4" - "@babel/types": "npm:^7.25.4" - source-map-js: "npm:^1.2.0" - checksum: 10/3a2dba6b0bdde957797361d09c7931ebdc1b30231705360eeb40ed458d28e1c3112841c3ed4e1b87ceb28f741e333c7673cd961193aa9fdb4f4946b202e6205a - languageName: node - linkType: hard - "mailparser@npm:^3.7.3": version: 3.7.3 resolution: "mailparser@npm:3.7.3" @@ -26539,15 +25908,6 @@ __metadata: languageName: node linkType: hard -"make-dir@npm:^4.0.0": - version: 4.0.0 - resolution: "make-dir@npm:4.0.0" - dependencies: - semver: "npm:^7.5.3" - checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a - languageName: node - linkType: hard - "make-error@npm:^1.1.1, make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" @@ -27681,13 +27041,6 @@ __metadata: languageName: node linkType: hard -"mrmime@npm:^2.0.0": - version: 2.0.1 - resolution: "mrmime@npm:2.0.1" - checksum: 10/1f966e2c05b7264209c4149ae50e8e830908eb64dd903535196f6ad72681fa109b794007288a3c2814f7a1ecf9ca192769909c0c374d974d604a8de5fc095d4a - languageName: node - linkType: hard - "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -28695,76 +28048,6 @@ __metadata: languageName: node linkType: hard -"oxlint-tsgolint@npm:~0.1.5": - version: 0.1.5 - resolution: "oxlint-tsgolint@npm:0.1.5" - dependencies: - "@oxlint-tsgolint/darwin-arm64": "npm:0.1.5" - "@oxlint-tsgolint/darwin-x64": "npm:0.1.5" - "@oxlint-tsgolint/linux-arm64": "npm:0.1.5" - "@oxlint-tsgolint/linux-x64": "npm:0.1.5" - "@oxlint-tsgolint/win32-arm64": "npm:0.1.5" - "@oxlint-tsgolint/win32-x64": "npm:0.1.5" - dependenciesMeta: - "@oxlint-tsgolint/darwin-arm64": - optional: true - "@oxlint-tsgolint/darwin-x64": - optional: true - "@oxlint-tsgolint/linux-arm64": - optional: true - "@oxlint-tsgolint/linux-x64": - optional: true - "@oxlint-tsgolint/win32-arm64": - optional: true - "@oxlint-tsgolint/win32-x64": - optional: true - bin: - tsgolint: bin/tsgolint.js - checksum: 10/163ce643bc4a33238fae0999a150b30c6e7548cfc44f30a045581c0fb007c5a17f5fe834c425d06047fe117c66a9b0f94cdbd2919c4e5dca4d481e7586497c71 - languageName: node - linkType: hard - -"oxlint@npm:~1.14.0": - version: 1.14.0 - resolution: "oxlint@npm:1.14.0" - dependencies: - "@oxlint/darwin-arm64": "npm:1.14.0" - "@oxlint/darwin-x64": "npm:1.14.0" - "@oxlint/linux-arm64-gnu": "npm:1.14.0" - "@oxlint/linux-arm64-musl": "npm:1.14.0" - "@oxlint/linux-x64-gnu": "npm:1.14.0" - "@oxlint/linux-x64-musl": "npm:1.14.0" - "@oxlint/win32-arm64": "npm:1.14.0" - "@oxlint/win32-x64": "npm:1.14.0" - peerDependencies: - oxlint-tsgolint: ">=0.1.5" - dependenciesMeta: - "@oxlint/darwin-arm64": - optional: true - "@oxlint/darwin-x64": - optional: true - "@oxlint/linux-arm64-gnu": - optional: true - "@oxlint/linux-arm64-musl": - optional: true - "@oxlint/linux-x64-gnu": - optional: true - "@oxlint/linux-x64-musl": - optional: true - "@oxlint/win32-arm64": - optional: true - "@oxlint/win32-x64": - optional: true - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - bin: - oxc_language_server: bin/oxc_language_server - oxlint: bin/oxlint - checksum: 10/b5b17f85250626a5b2993e11b4c1a22c55f07765eb4e6b12b5977ec759c7586fd81d9b71cc23f473fe385928dc02e3c7d982d0bd4e26e2b420f94a9e8618a32c - languageName: node - linkType: hard - "p-cancelable@npm:^0.3.0": version: 0.3.0 resolution: "p-cancelable@npm:0.3.0" @@ -29392,13 +28675,6 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^2.0.3": - version: 2.0.3 - resolution: "pathe@npm:2.0.3" - checksum: 10/01e9a69928f39087d96e1751ce7d6d50da8c39abf9a12e0ac2389c42c83bc76f78c45a475bd9026a02e6a6f79be63acc75667df855862fe567d99a00a540d23d - languageName: node - linkType: hard - "pathington@npm:^1.1.7": version: 1.1.7 resolution: "pathington@npm:1.1.7" @@ -29483,13 +28759,6 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": - version: 1.1.1 - resolution: "picocolors@npm:1.1.1" - checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 - languageName: node - linkType: hard - "picocolors@npm:^0.2.1": version: 0.2.1 resolution: "picocolors@npm:0.2.1" @@ -29497,6 +28766,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.0, picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -29511,13 +28787,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 - languageName: node - linkType: hard - "pidtree@npm:^0.3.0": version: 0.3.1 resolution: "pidtree@npm:0.3.1" @@ -29642,17 +28911,6 @@ __metadata: languageName: node linkType: hard -"pixelmatch@npm:7.1.0": - version: 7.1.0 - resolution: "pixelmatch@npm:7.1.0" - dependencies: - pngjs: "npm:^7.0.0" - bin: - pixelmatch: bin/pixelmatch - checksum: 10/57a122196318ea8ce74e8759b1b7b94b9f9627b495cd79e50a49d470dc23b6c679e89c38660d0f7e8f959eac3b279c55b728e52d02c276dc51505f06eaba1141 - languageName: node - linkType: hard - "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -29718,7 +28976,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:1.55.0, playwright@npm:^1.55.0": +"playwright@npm:1.55.0": version: 1.55.0 resolution: "playwright@npm:1.55.0" dependencies: @@ -29757,13 +29015,6 @@ __metadata: languageName: node linkType: hard -"pngjs@npm:^7.0.0": - version: 7.0.0 - resolution: "pngjs@npm:7.0.0" - checksum: 10/e843ebbb0df092ee0f3a3e7dbd91ff87a239a4e4c4198fff202916bfb33b67622f4b83b3c29f3ccae94fcb97180c289df06068624554f61686fe6b9a4811f7db - languageName: node - linkType: hard - "pngquant-bin@npm:^6.0.0": version: 6.0.1 resolution: "pngquant-bin@npm:6.0.1" @@ -32629,81 +31880,6 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.43.0": - version: 4.46.2 - resolution: "rollup@npm:4.46.2" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.46.2" - "@rollup/rollup-android-arm64": "npm:4.46.2" - "@rollup/rollup-darwin-arm64": "npm:4.46.2" - "@rollup/rollup-darwin-x64": "npm:4.46.2" - "@rollup/rollup-freebsd-arm64": "npm:4.46.2" - "@rollup/rollup-freebsd-x64": "npm:4.46.2" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.46.2" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.46.2" - "@rollup/rollup-linux-arm64-gnu": "npm:4.46.2" - "@rollup/rollup-linux-arm64-musl": "npm:4.46.2" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.46.2" - "@rollup/rollup-linux-ppc64-gnu": "npm:4.46.2" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.46.2" - "@rollup/rollup-linux-riscv64-musl": "npm:4.46.2" - "@rollup/rollup-linux-s390x-gnu": "npm:4.46.2" - "@rollup/rollup-linux-x64-gnu": "npm:4.46.2" - "@rollup/rollup-linux-x64-musl": "npm:4.46.2" - "@rollup/rollup-win32-arm64-msvc": "npm:4.46.2" - "@rollup/rollup-win32-ia32-msvc": "npm:4.46.2" - "@rollup/rollup-win32-x64-msvc": "npm:4.46.2" - "@types/estree": "npm:1.0.8" - fsevents: "npm:~2.3.2" - dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-freebsd-arm64": - optional: true - "@rollup/rollup-freebsd-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": - optional: true - "@rollup/rollup-linux-arm-musleabihf": - optional: true - "@rollup/rollup-linux-arm64-gnu": - optional: true - "@rollup/rollup-linux-arm64-musl": - optional: true - "@rollup/rollup-linux-loongarch64-gnu": - optional: true - "@rollup/rollup-linux-ppc64-gnu": - optional: true - "@rollup/rollup-linux-riscv64-gnu": - optional: true - "@rollup/rollup-linux-riscv64-musl": - optional: true - "@rollup/rollup-linux-s390x-gnu": - optional: true - "@rollup/rollup-linux-x64-gnu": - optional: true - "@rollup/rollup-linux-x64-musl": - optional: true - "@rollup/rollup-win32-arm64-msvc": - optional: true - "@rollup/rollup-win32-ia32-msvc": - optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: 10/3acc425a9828e8ba75eaec9cb506a680d3deaa09a753635140cc4a3c98445ba6e3f631b32ca1c98965b3f60672d2815cd560ea844bba497d69e65c58c02327cf - languageName: node - linkType: hard - "rrweb-cssom@npm:^0.8.0": version: 0.8.0 resolution: "rrweb-cssom@npm:0.8.0" @@ -33403,13 +32579,6 @@ __metadata: languageName: node linkType: hard -"siginfo@npm:^2.0.0": - version: 2.0.0 - resolution: "siginfo@npm:2.0.0" - checksum: 10/e93ff66c6531a079af8fb217240df01f980155b5dc408d2d7bebc398dd284e383eb318153bf8acd4db3c4fe799aa5b9a641e38b0ba3b1975700b1c89547ea4e7 - languageName: node - linkType: hard - "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -33472,17 +32641,6 @@ __metadata: languageName: node linkType: hard -"sirv@npm:^3.0.1": - version: 3.0.1 - resolution: "sirv@npm:3.0.1" - dependencies: - "@polka/url": "npm:^1.0.0-next.24" - mrmime: "npm:^2.0.0" - totalist: "npm:^3.0.0" - checksum: 10/b110ebe28eb1740772fbbfacb6c71c58d1ec8ec17a5ae2852a5418c3ef41d52d473663613de808f8a6337ec29dd446414d0d059e75bfd13fb9630d18651c99f2 - languageName: node - linkType: hard - "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -33661,7 +32819,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3 @@ -33962,13 +33120,6 @@ __metadata: languageName: node linkType: hard -"stackback@npm:0.0.2": - version: 0.0.2 - resolution: "stackback@npm:0.0.2" - checksum: 10/2d4dc4e64e2db796de4a3c856d5943daccdfa3dd092e452a1ce059c81e9a9c29e0b9badba91b43ef0d5ff5c04ee62feb3bcc559a804e16faf447bac2d883aa99 - languageName: node - linkType: hard - "stackframe@npm:^1.1.1": version: 1.2.1 resolution: "stackframe@npm:1.2.1" @@ -33990,13 +33141,6 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.9.0": - version: 3.9.0 - resolution: "std-env@npm:3.9.0" - checksum: 10/3044b2c54a74be4f460db56725571241ab3ac89a91f39c7709519bc90fa37148784bc4cd7d3a301aa735f43bd174496f263563f76703ce3e81370466ab7c235b - languageName: node - linkType: hard - "stealthy-require@npm:^1.1.1": version: 1.1.1 resolution: "stealthy-require@npm:1.1.1" @@ -34445,15 +33589,6 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-literal@npm:3.0.0" - dependencies: - js-tokens: "npm:^9.0.1" - checksum: 10/da1616f654f3ff481e078597b4565373a5eeed78b83de4a11a1a1b98292a9036f2474e528eff19b6eed93370428ff957a473827057c117495086436725d7efad - languageName: node - linkType: hard - "strip-outer@npm:^1.0.0, strip-outer@npm:^1.0.1": version: 1.0.1 resolution: "strip-outer@npm:1.0.1" @@ -35225,30 +34360,6 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.9.0": - version: 2.9.0 - resolution: "tinybench@npm:2.9.0" - checksum: 10/cfa1e1418e91289219501703c4693c70708c91ffb7f040fd318d24aef419fb5a43e0c0160df9471499191968b2451d8da7f8087b08c3133c251c40d24aced06c - languageName: node - linkType: hard - -"tinyexec@npm:^0.3.2": - version: 0.3.2 - resolution: "tinyexec@npm:0.3.2" - checksum: 10/b9d5fed3166fb1acd1e7f9a89afcd97ccbe18b9c1af0278e429455f6976d69271ba2d21797e7c36d57d6b05025e525d2882d88c2ab435b60d1ddf2fea361de57 - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.14": - version: 0.2.14 - resolution: "tinyglobby@npm:0.2.14" - dependencies: - fdir: "npm:^6.4.4" - picomatch: "npm:^4.0.2" - checksum: 10/3d306d319718b7cc9d79fb3f29d8655237aa6a1f280860a217f93417039d0614891aee6fc47c5db315f4fcc6ac8d55eb8e23e2de73b2c51a431b42456d9e5764 - languageName: node - linkType: hard - "tinykeys@npm:^1.4.0": version: 1.4.0 resolution: "tinykeys@npm:1.4.0" @@ -35256,13 +34367,6 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^2.0.0": - version: 2.0.0 - resolution: "tinypool@npm:2.0.0" - checksum: 10/0f9dbeb25b2f0d8243321d8044254accf35b14d1c5b343115dd86ef3a0aacd64b68fc6d319ea714a295165ed355e93d031757a7505067448ffd0fa688697a80d - languageName: node - linkType: hard - "tinyrainbow@npm:^1.2.0": version: 1.2.0 resolution: "tinyrainbow@npm:1.2.0" @@ -35270,13 +34374,6 @@ __metadata: languageName: node linkType: hard -"tinyrainbow@npm:^2.0.0": - version: 2.0.0 - resolution: "tinyrainbow@npm:2.0.0" - checksum: 10/94d4e16246972614a5601eeb169ba94f1d49752426312d3cf8cc4f2cc663a2e354ffc653aa4de4eebccbf9eeebdd0caef52d1150271fdfde65d7ae7f3dcb9eb5 - languageName: node - linkType: hard - "tinyspy@npm:^3.0.0": version: 3.0.2 resolution: "tinyspy@npm:3.0.2" @@ -35385,13 +34482,6 @@ __metadata: languageName: node linkType: hard -"totalist@npm:^3.0.0": - version: 3.0.1 - resolution: "totalist@npm:3.0.1" - checksum: 10/5132d562cf88ff93fd710770a92f31dbe67cc19b5c6ccae2efc0da327f0954d211bbfd9456389655d726c624f284b4a23112f56d1da931ca7cfabbe1f45e778a - languageName: node - linkType: hard - "tough-cookie@npm:^2.3.3, tough-cookie@npm:~2.5.0": version: 2.5.0 resolution: "tough-cookie@npm:2.5.0" @@ -36777,61 +35867,6 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0 || ^7.0.0-0": - version: 7.1.2 - resolution: "vite@npm:7.1.2" - dependencies: - esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.6" - fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.3" - postcss: "npm:^8.5.6" - rollup: "npm:^4.43.0" - tinyglobby: "npm:^0.2.14" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - jiti: ">=1.21.0" - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10/942188896db181bd5f95c576303eb617cd08580eb129d0223bc87a8ed9b8dc0d60d8487c077fd9a01087c55600af132c5fc677e51b4b48a4a54a376056969edb - languageName: node - linkType: hard - "vite@npm:^6.2.4": version: 6.2.4 resolution: "vite@npm:6.2.4" @@ -36884,60 +35919,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:4.0.0-beta.10": - version: 4.0.0-beta.10 - resolution: "vitest@npm:4.0.0-beta.10" - dependencies: - "@vitest/expect": "npm:4.0.0-beta.10" - "@vitest/mocker": "npm:4.0.0-beta.10" - "@vitest/pretty-format": "npm:^4.0.0-beta.10" - "@vitest/runner": "npm:4.0.0-beta.10" - "@vitest/snapshot": "npm:4.0.0-beta.10" - "@vitest/spy": "npm:4.0.0-beta.10" - "@vitest/utils": "npm:4.0.0-beta.10" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - expect-type: "npm:^1.2.2" - magic-string: "npm:^0.30.18" - pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.3" - std-env: "npm:^3.9.0" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^2.0.0" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^6.0.0 || ^7.0.0-0" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 4.0.0-beta.10 - "@vitest/ui": 4.0.0-beta.10 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/debug": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10/d1edcb237cfd1539b5876812de940a305cb74165cc83a24a45aaf73a11bec6cf76746e4f83427464a26a89d40697ca2e3ad5e4bdc9e14079e6391e620de8ab9e - languageName: node - linkType: hard - "vm-browserify@npm:^1.0.0, vm-browserify@npm:^1.1.2": version: 1.1.2 resolution: "vm-browserify@npm:1.1.2" @@ -37461,18 +36442,6 @@ __metadata: languageName: node linkType: hard -"why-is-node-running@npm:^2.3.0": - version: 2.3.0 - resolution: "why-is-node-running@npm:2.3.0" - dependencies: - siginfo: "npm:^2.0.0" - stackback: "npm:0.0.2" - bin: - why-is-node-running: cli.js - checksum: 10/0de6e6cd8f2f94a8b5ca44e84cf1751eadcac3ebedcdc6e5fbbe6c8011904afcbc1a2777c53496ec02ced7b81f2e7eda61e76bf8262a8bc3ceaa1f6040508051 - languageName: node - linkType: hard - "wide-align@npm:^1.1.2": version: 1.1.5 resolution: "wide-align@npm:1.1.5" @@ -37642,21 +36611,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.3": - version: 8.18.3 - resolution: "ws@npm:8.18.3" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 - languageName: node - linkType: hard - "xml-crypto@npm:~3.2.1": version: 3.2.1 resolution: "xml-crypto@npm:3.2.1" From cc75cf16cf6fd7f43233487dcf03a85335109a60 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 12 Sep 2025 16:33:51 -0300 Subject: [PATCH 155/251] update readme --- apps/meteor/client/lib/e2ee/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md index 74b47bea0b2cf..b2e4e9beea74f 100644 --- a/apps/meteor/client/lib/e2ee/README.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -1,6 +1,23 @@ # Notes -## History +## 1. Summary + +### 1.1 User Identity & Master Key +A BIP39 Mnemonic (from high-entropy randomness) is passed through PBKDF2 (with a salt and high iteration count) to create a strong, memorable master key. + +### 1.2 Long-Term Identity Keys +A long-term RSA key pair is generated randomly. The private key is then encrypted with the master key for secure server backup and recovery. The public key is shared with others. + +### 1.3 Message Encryption +For any given chat, a symmetric AES-GCM key is generated. This key is used to encrypt the actual messages because it's extremely fast and efficient. + +### 1.4 Key Distribution & Group Management +When a member needs the AES key (either to start a chat or join a group), an existing member encrypts it using the recipient's Public RSA key. + +### 1.5 Forward Secrecy (NOT implemented) +Whenever a group's membership changes (a user is added or removed), a completely new AES-GCM key is generated and securely distributed to all current members. This protects the integrity of the conversation going forward. + +## 2. History - [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) - [Fix: Change wording on e2e to make a little more clear](https://github.com/RocketChat/Rocket.Chat/pull/12124) - [Fix: e2e password visible on always-on alert message](https://github.com/RocketChat/Rocket.Chat/pull/12139) From a4886c929b371dc8064207dd67e5c4ce6be4c600 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 13 Sep 2025 13:34:15 -0300 Subject: [PATCH 156/251] fix last message decryption --- apps/meteor/client/lib/e2ee/helper.ts | 19 +- apps/meteor/client/lib/e2ee/logger.ts | 66 ++++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 150 +++++++------ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 200 +++++++++--------- .../root/hooks/loggedIn/useE2EEncryption.ts | 3 +- .../core-typings/src/IEncryptedContent.ts | 25 +++ .../core-typings/src/IMessage/IMessage.ts | 12 +- packages/core-typings/src/IUpload.ts | 11 +- 8 files changed, 286 insertions(+), 200 deletions(-) create mode 100644 packages/core-typings/src/IEncryptedContent.ts diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 9f942a7b9b509..843e87b358d0a 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -33,11 +33,11 @@ export function joinVectorAndEncryptedData(vector: Uint8Array, encr return output; } -export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16 = 16): [ArrayBuffer, ArrayBuffer] { +export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16 = 16): [Uint8Array, Uint8Array] { const vector = cipherText.slice(0, ivLength); const encryptedData = cipherText.slice(ivLength); - return [vector.buffer, encryptedData.buffer]; + return [vector, encryptedData]; } export async function encryptRSA(key: CryptoKey, data: BufferSource) { @@ -46,31 +46,32 @@ export async function encryptRSA(key: CryptoKey, data: BufferSource) { /** * Encrypts data using AES-CBC. - * @param vector The initialization vector. + * @param iv The initialization vector. * @param key The encryption key. * @param data The data to encrypt. * @returns The encrypted data. * @deprecated Use {@link encryptAesGcm} instead. */ -export async function encryptAesCbc(vector: Uint8Array, key: CryptoKey, data: Uint8Array) { - return crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data); +export async function encryptAesCbc(iv: BufferSource, key: CryptoKey, data: BufferSource) { + const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data); + return encrypted; } -export async function decryptAesCbc(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { +export async function decryptAesCbc(iv: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data); } -export async function encryptAesGcm(iv: ArrayBuffer, key: CryptoKey, data: BufferSource) { +export async function encryptAesGcm(iv: BufferSource, key: CryptoKey, data: BufferSource) { const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); return encrypted; } -export async function decryptAesGcm(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { +export async function decryptAesGcm(iv: BufferSource, key: CryptoKey, data: BufferSource) { const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); return decrypted; } -export function decryptAes(iv: ArrayBuffer, key: CryptoKey, data: ArrayBuffer) { +export function decryptAes(iv: BufferSource, key: CryptoKey, data: BufferSource) { if (key.algorithm.name === 'AES-GCM') { return decryptAesGcm(iv, key, data); } diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 7a37c69902795..06738e08dff6b 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,22 +1,60 @@ type LogLevel = 'info' | 'warn' | 'error'; const styles: Record = { - info: 'color: green; background-color: white; font-weight: bold;', - warn: 'color: orange; background-color: white; font-weight: bold;', - error: 'color: red; background-color: white; font-weight: bold;', + info: 'font-weight: bold;', + warn: 'color: black; background-color: yellow; font-weight: bold;', + error: 'color: white; background-color: red; font-weight: bold;', }; -const icon: Record = { - info: 'ⓘ', - warn: '⚠️', - error: '❌', -}; +class Logger { + static instances: Map> = new Map(); + + title: string; + + constructor(title: string) { + if (Logger.instances.has(title)) { + throw new Error(`Logger with title "${title}" already exists.`); + } + this.title = title; + } + + span(label: string) { + return new Span(this, label); + } +} + +class Span { + logger: WeakRef; + + label: string; -export const createLogger = (label: string) => { - return (level: LogLevel, title: string, message: unknown, ...params: unknown[]) => { - console.groupCollapsed(`${icon[level]} %c[${label}:${title}]`, styles[level]); - console.log(message, ...params); - console.trace(); + constructor(logger: Logger, label: string) { + this.logger = new WeakRef(logger); + this.label = label; + } + + private log(level: LogLevel, message: string, ...params: unknown[]) { + console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]`, styles[level]); + console.trace(message); + if (params.length) { + console.log(...params); + } console.groupEnd(); - }; + } + + info(message: string, ...params: unknown[]) { + this.log('info', message, ...params); + } + + warn(message: string, ...params: unknown[]) { + this.log('warn', message, ...params); + } + + error(message: string, ...params: unknown[]) { + this.log('error', message, ...params); + } +} + +export const createLogger = (title: string) => { + return new Logger(title); }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 113369b08cc4d..0a71d59ec5061 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -94,6 +94,8 @@ export class E2ERoom extends Emitter { sessionKeyExported: JsonWebKey | undefined; + cache: WeakMap = new WeakMap(); + constructor(userId: string, room: IRoom) { super(); @@ -121,16 +123,17 @@ export class E2ERoom extends Emitter { } setState(requestedState: E2ERoomState) { + const span = log.span('setState'); const currentState = this.state; const nextState = filterMutation(currentState, requestedState); if (!nextState) { - log('error', 'setState', `${currentState} -> ${requestedState}`); + span.error(`${currentState} -> ${requestedState}`); return; } this.state = nextState; - log('info', 'setState', `${currentState} -> ${nextState}`); + span.info(`${currentState} -> ${nextState}`); this.emit('STATE_CHANGED', currentState); this.emit(nextState, this); } @@ -204,29 +207,45 @@ export class E2ERoom extends Emitter { } async decryptSubscription() { - const subscription = Subscriptions.state.find(({ rid, lastMessage }) => rid === this.roomId && !!lastMessage && lastMessage.t === 'e2e' && lastMessage.e2e === 'pending'); + const span = log.span('decryptSubscription'); + const subscription = Subscriptions.state.find((record) => record.rid === this.roomId); + + if (!subscription) { + span.warn('no subscription found'); + return; + } + + const { lastMessage } = subscription; - if (subscription?.lastMessage?.t !== 'e2e') { - log('info', 'decryptSubscriptions', 'Nothing to do'); + if (lastMessage === undefined) { + span.warn('no lastMessage found'); return; } - const message = await this.decryptMessage(subscription.lastMessage); + if (lastMessage.t !== 'e2e') { + span.warn('nothing to do'); + return; + } + + const message = await this.decryptMessage(lastMessage); - if (message !== subscription.lastMessage) { - log('info', 'decryptSubscriptions', 'Updating last message', { before: subscription.lastMessage, after: message }); + if (message.msg !== subscription.lastMessage?.msg) { + span.info('decryptSubscriptions updating lastMessage'); Subscriptions.state.store({ ...subscription, lastMessage: message, }); } + + span.info('decryptSubscriptions Done'); } async decryptOldRoomKeys() { + const span = log.span('decryptOldRoomKeys'); const sub = Subscriptions.state.find((record) => record.rid === this.roomId); if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { - log('info', 'decryptOldRoomKeys', 'Nothing to do'); + span.info('Nothing to do'); return; } @@ -239,9 +258,7 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - log( - 'error', - 'decryptOldRoomKeys', + span.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, e, ); @@ -253,8 +270,9 @@ export class E2ERoom extends Emitter { } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { + const span = log.span('exportOldRoomKeys'); if (!oldKeys || oldKeys.length === 0) { - log('info', 'exportOldRoomKeys', 'Nothing to do'); + span.info('Nothing to do'); return; } @@ -271,27 +289,27 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { - log( - 'error', + span.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, e, ); } } - log('info', 'exportOldRoomKeys', `Done: ${keys.length} keys exported`); + span.info(`Done: ${keys.length} keys exported`); return keys; } async decryptPendingMessages() { await Messages.state.updateAsync( (record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending', - (record) => this.decryptMessage(record), + (record) => this.decryptMessage(record as IE2EEMessage), ); } // Initiates E2E Encryption async handshake() { + const span = log.span('handshake'); if (!e2e.isReady()) { return; } @@ -311,7 +329,7 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - log('error', 'Error fetching group key: ', error); + span.error('Error fetching group key: ', error); return; } @@ -325,10 +343,10 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - log('info', 'Requesting room key', room.e2eKeyId); + span.info('Requesting room key', room.e2eKeyId); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - log('error', 'Error during handshake: ', error); + span.error('Error during handshake: ', error); this.setState('ERROR'); } } @@ -354,13 +372,14 @@ export class E2ERoom extends Emitter { } async importGroupKey(groupKey: string) { + const span = log.span('importGroupKey'); // Get existing group key const keyID = groupKey.slice(0, 36); groupKey = groupKey.slice(36); const decodedGroupKey = Base64.decode(groupKey); if (this.keyID === keyID && this.groupSessionKey) { - log('info', 'importGroupKey', 'Key already imported'); + span.info('Key already imported'); return true; } @@ -372,7 +391,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - log('error', 'Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); + span.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); return false; } @@ -388,7 +407,7 @@ export class E2ERoom extends Emitter { // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - log('error', 'Error importing group key: ', error); + span.error('Error importing group key: ', error); return false; } @@ -419,9 +438,9 @@ export class E2ERoom extends Emitter { } async resetRoomKey() { - log('info', 'resetRoomKey', 'started', { currentKeyID: this.keyID }); + const span = log.span('resetRoomKey'); if (!e2e.publicKey) { - log('error', 'Cannot reset room key', 'No public key found.', e2e); + span.error('Cannot reset room key', 'No public key found.', e2e); return; } @@ -432,17 +451,24 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - log('info', 'Room key', 'reset successfully', e2eNewKeys); + span.info('Room key reset successfully', e2eNewKeys); return e2eNewKeys; } catch (error) { - log('error', 'resetRoomKey', 'failed', error); + span.error('resetRoomKey failed', error); throw error; } } onRoomKeyReset(keyID: string) { - log('info', 'onRoomKeyReset', 'old:', this.keyID, 'new:', keyID); + const span = log.span('onRoomKeyReset'); + + if (this.keyID === keyID) { + span.warn('Key ID matches current key, nothing to do'); + return; + } + + span.info('Room key has been reset', { oldKeyID: this.keyID, newKeyID: keyID }); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = undefined; @@ -452,6 +478,7 @@ export class E2ERoom extends Emitter { } async encryptKeyForOtherParticipants() { + const span = log.span('encryptKeyForOtherParticipants'); // Encrypt generated session key for every user in room and publish to subscription model. try { const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); @@ -487,11 +514,12 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return log('error', 'Error getting room users: ', error); + return span.error('Error getting room users: ', error); } } async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { + const span = log.span('encryptOldKeysForParticipant'); if (!oldRoomKeys || oldRoomKeys.length === 0) { return; } @@ -501,7 +529,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return log('error', 'Error importing user key: ', error); + return span.error('Error importing user key', error); } try { @@ -517,16 +545,17 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return log('error', 'failed to encrypt old keys for participant', error); + return span.error('failed to encrypt old keys for participant', error); } } async encryptGroupKeyForParticipant(publicKey: string) { + const span = log.span('encryptGroupKeyForParticipant'); let userKey; try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return log('error', 'importing user key', error); + return span.error('error', 'importing user key', error); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -536,7 +565,7 @@ export class E2ERoom extends Emitter { const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; } catch (error) { - return log('error', 'Error encrypting user key: ', error); + return span.error('Error encrypting user key: ', error); } } @@ -545,6 +574,7 @@ export class E2ERoom extends Emitter { // if (!this.isSupportedRoomType(this.typeOfRoom)) { // return; // } + const span = log.span('encryptFile'); const fileArrayBuffer = await readFileAsArrayBuffer(file); @@ -556,7 +586,7 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - return log('error', 'Error encrypting group key: ', error); + return span.error('Error encrypting group key: ', error); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); @@ -584,6 +614,7 @@ export class E2ERoom extends Emitter { // Encrypts messages async encryptText(data: Uint8Array) { + const span = log.span('encryptText'); const vector = crypto.getRandomValues(new Uint8Array(12)); try { @@ -596,10 +627,10 @@ export class E2ERoom extends Emitter { iv: Base64.encode(vector), ciphertext: Base64.encode(new Uint8Array(result)), }; - log('info', 'encryptText', ciphertext); + span.info('encrypted', ciphertext); return ciphertext; } catch (error) { - log('error', 'Error encrypting message: ', error); + span.error('Error encrypting message: ', error); throw error; } } @@ -663,12 +694,12 @@ export class E2ERoom extends Emitter { const { content } = data; - if (content && content.algorithm === 'rc.v1.aes-sha2') { - const decrypted = await this.decrypt(content.ciphertext); + if (content.algorithm === 'rc.v1.aes-sha2') { + const decrypted = await this.decrypt(content); Object.assign(data, decrypted); } - if (content && content.algorithm === 'rc.v2.aes-sha2' && 'iv' in content) { + if (content.algorithm === 'rc.v2.aes-sha2') { const decrypted = await this.decrypt(content); Object.assign(data, decrypted); } @@ -677,44 +708,36 @@ export class E2ERoom extends Emitter { } // Decrypt messages - async decryptMessage(message: IMessage | IE2EEMessage): Promise { - if (message.t !== 'e2e' || message.e2e === 'done') { + async decryptMessage(message: IMessage): Promise { + if (message.e2e === 'done') { return message; } - if (message.msg) { - const data = await this.decrypt(message.msg); - - if (typeof data.msg === 'undefined') { - message.msg = data.text; - } - } - message = await this.decryptContent(message); - return { ...message, e2e: 'done' as const, }; } - async doDecrypt(vector: ArrayBuffer, key: CryptoKey, cipherText: ArrayBuffer): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date }> { - const result = await decryptAes(vector, key, cipherText); - const parsed = EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); - log('info', 'decrypted', parsed); + async doDecrypt(iv: Uint8Array, key: CryptoKey, ciphertext: Uint8Array): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date }> { + const span = log.span('doDecrypt'); + const result = await decryptAes(iv.buffer, key, ciphertext.buffer); + const parsed = EJSON.parse(new TextDecoder('UTF-8').decode(result)); + span.info('decrypted', parsed); return parsed; } // Parses the message to extract the keyID and cipherText parse(payload: string | Required['content']): { key_id: string; - iv: ArrayBuffer; - ciphertext: ArrayBuffer; + iv: Uint8Array; + ciphertext: Uint8Array; } { // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} if (typeof payload !== 'string' && payload.algorithm === 'rc.v2.aes-sha2') { - const iv = Base64.decode(payload.iv).buffer; - const ciphertext = Base64.decode(payload.ciphertext).buffer; + const iv = Base64.decode(payload.iv); + const ciphertext = Base64.decode(payload.ciphertext); return { key_id: payload.key_id, iv, ciphertext }; } @@ -729,9 +752,9 @@ export class E2ERoom extends Emitter { }; } - async decrypt(message: string | Required['content']): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { + async decrypt(message: Required['content']): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { + const span = log.span('decrypt'); const payload = this.parse(message); - let oldKey = null; if (payload.key_id !== this.keyID) { const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === payload.key_id); @@ -746,14 +769,13 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - log( - 'warn', + span.warn( 'Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', { message: result }, ); return result; } catch (error) { - log('error', 'Error decrypting message: ', error, message); + span.error('Error decrypting message: ', error, message); return { msg: t('E2E_indecipherable') }; } } @@ -771,7 +793,7 @@ export class E2ERoom extends Emitter { const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); return result; } catch (error) { - log('error', 'decrypting', error, message); + span.error('decrypting', error); return { msg: t('E2E_Key_Error') }; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 2194ff21556cf..6e467cdef124e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,10 +1,12 @@ import QueryString from 'querystring'; import URL from 'url'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; +import { Base64 } from '@rocket.chat/base64'; +import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import EJSON from 'ejson'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; @@ -14,16 +16,17 @@ import type { E2EEState } from './E2EEState'; import { toString, toArrayBuffer, - joinVectorAndEncryptedData, + // joinVectorAndEncryptedData, splitVectorAndEncryptedData, encryptAesGcm, - decryptAesGcm, + // decryptAesGcm, generateRSAKey, exportJWKKey, importRSAKey, importRawKey, deriveKey, generateMnemonicPhrase, + decryptAes, } from './helper'; import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -121,8 +124,9 @@ class E2E extends Emitter { this.observeSubscriptions(); } - async onSubscriptionChanged(sub: ISubscription) { - log('info', 'subscriptionChanged', sub); + async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise { + const span = log.span('onSubscriptionChanged'); + span.info('subscriptionChanged', sub.lastMessage); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -138,7 +142,7 @@ class E2E extends Emitter { await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - log('warn', 'Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + span.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); await this.rejectSuggestedKey(sub.rid); } } @@ -160,15 +164,19 @@ class E2E extends Emitter { return; } - await e2eRoom.decryptSubscription(); + if (sub.lastMessage?.e2e !== 'done') { + await e2eRoom.decryptSubscription(); + } } private unsubscribeFromSubscriptions: (() => void) | undefined; observeSubscriptions() { + const span = log.span('observeSubscriptions'); this.unsubscribeFromSubscriptions?.(); - this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => { + this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe(async (state) => { + const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey); const subscribed = new Set(subscriptions.map((sub) => sub.rid)); @@ -176,13 +184,11 @@ class E2E extends Emitter { const excess = instatiated.difference(subscribed); if (excess.size) { - log('info', 'Unsubscribing from excess instances', excess); + span.info('Unsubscribing from excess instances', excess); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } - for (const sub of subscriptions) { - void this.onSubscriptionChanged(sub); - } + await Promise.all(subscriptions.map((sub) => this.onSubscriptionChanged(sub))); }); } @@ -192,17 +198,19 @@ class E2E extends Emitter { } setState(nextState: E2EEState) { + const span = log.span('setState'); const prevState = this.state; this.state = nextState; - log('info', 'e2eStateChanged', `${prevState} -> ${nextState}`); + span.info(`${prevState} -> ${nextState}`); this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); } async handleAsyncE2EESuggestedKey() { + const span = log.span('handleAsyncE2EESuggestedKey'); const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( subs @@ -215,11 +223,11 @@ class E2E extends Emitter { } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - log('info', 'importedE2ESuggestedKey', sub.E2ESuggestedKey); + span.info('importedE2ESuggestedKey', sub.E2ESuggestedKey); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - log('error', 'invalidE2ESuggestedKey', sub.E2ESuggestedKey); + span.error('invalidE2ESuggestedKey', sub.E2ESuggestedKey); await e2e.rejectSuggestedKey(sub.rid); } @@ -244,17 +252,9 @@ class E2E extends Emitter { }); } - async getInstanceByRoomId(rid: IRoom['_id']): Promise { + async getInstanceByRoomId(rid: IRoom['_id']): Promise { const room = await this.waitForRoom(rid); - if (room.t !== 'd' && room.t !== 'p') { - return null; - } - - if (!room.encrypted) { - return null; - } - const userId = Meteor.userId(); if (!this.instancesByRoomId[rid] && userId) { this.instancesByRoomId[rid] = new E2ERoom(userId, room); @@ -294,7 +294,7 @@ class E2E extends Emitter { await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { public_key, - private_key: encodedPrivateKey, + private_key: JSON.stringify(encodedPrivateKey), force, }); } @@ -344,11 +344,12 @@ class E2E extends Emitter { } async startClient(): Promise { + const span = log.span('startClient'); if (this.started) { return; } - log('info', 'startClient', this.state); + span.info(this.state); this.started = true; @@ -378,7 +379,7 @@ class E2E extends Emitter { this.closeAlert(); }, }); - return log('error', 'E2E -> Error decoding private key: ', error); + return span.error('E2E -> Error decoding private key: ', error); } } @@ -410,7 +411,8 @@ class E2E extends Emitter { } async stopClient(): Promise { - log('info', 'stopClient', this.state); + const span = log.span('stopClient'); + span.info(this.state); this.closeAlert(); Accounts.storageLocation.removeItem('public_key'); @@ -433,6 +435,7 @@ class E2E extends Emitter { } async loadKeysFromDB(): Promise { + const span = log.span('loadKeysFromDB'); try { this.setState('LOADING_KEYS'); const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys'); @@ -441,7 +444,7 @@ class E2E extends Emitter { this.db_private_key = private_key; } catch (error) { this.setState('ERROR'); - log('error', 'Error fetching RSA keys: ', error); + span.error('Error fetching RSA keys: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation throw error; @@ -449,6 +452,7 @@ class E2E extends Emitter { } async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { + const span = log.span('loadKeys'); Accounts.storageLocation.setItem('public_key', public_key); this.publicKey = public_key; @@ -458,11 +462,12 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { this.setState('ERROR'); - return log('error', 'Error importing private key: ', error); + return span.error('Error importing private key: ', error); } } async createAndLoadKeys(): Promise { + const span = log.span('createAndLoadKeys'); // Could not obtain public-private keypair from server. this.setState('LOADING_KEYS'); let key; @@ -471,7 +476,7 @@ class E2E extends Emitter { this.privateKey = key.privateKey; } catch (error) { this.setState('ERROR'); - return log('error', 'Error generating key: ', error); + return span.error('Error generating key: ', error); } try { @@ -481,7 +486,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { this.setState('ERROR'); - return log('error', 'Error exporting public key: ', error); + return span.error('Error exporting public key: ', error); } try { @@ -490,7 +495,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { this.setState('ERROR'); - return log('error', 'Error exporting private key: ', error); + return span.error('Error exporting private key: ', error); } await this.requestSubscriptionKeys(); @@ -506,44 +511,21 @@ class E2E extends Emitter { return randomPassword; } - async encodePrivateKey(privateKey: string, password: string): Promise { + async encodePrivateKey(privateKey: string, password: string) { const masterKey = await this.getMasterKey(password); const vector = crypto.getRandomValues(new Uint8Array(12)); - try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const encodedPrivateKey = await encryptAesGcm(vector.buffer, masterKey, toArrayBuffer(privateKey)); - - return EJSON.stringify(joinVectorAndEncryptedData(vector, encodedPrivateKey)); - } catch (error) { - this.setState('ERROR'); - return log('error', 'Error encrypting encodedPrivateKey: ', error); - } + const result = await encryptAesGcm(vector.buffer, masterKey, toArrayBuffer(privateKey)); + return { + iv: Base64.encode(vector), + ciphertext: Base64.encode(new Uint8Array(result)), + }; } - async getMasterKey(password: string): Promise { - if (password == null) { - alert('You should provide a password'); - } - - // First, create a PBKDF2 "key" containing the password - let baseKey; - try { - baseKey = await importRawKey(toArrayBuffer(password)); - } catch (error) { - this.setState('ERROR'); - return log('error', 'Error creating a key based on user password: ', error); - } - - // Derive a key from the password - try { - return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); - } catch (error) { - this.setState('ERROR'); - return log('error', 'Error deriving baseKey: ', error); - } + async getMasterKey(password: string): Promise { + const baseKey = await importRawKey(toArrayBuffer(password)); + const derivedKey = await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); + return derivedKey; } openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { @@ -603,14 +585,14 @@ class E2E extends Emitter { return; } - const [vector, cipherText] = splitVectorAndEncryptedData(EJSON.parse(this.db_private_key), 12); + const { iv, ciphertext } = this.parsePrivateKey(this.db_private_key); try { if (!masterKey) { throw new Error('Error getting master key'); } - const privKey = await decryptAesGcm(vector, masterKey, cipherText); - const privateKey = toString(privKey) as string; + const privKey = await decryptAes(iv, masterKey, ciphertext); + const privateKey = toString(privKey); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); @@ -628,18 +610,38 @@ class E2E extends Emitter { } } + parsePrivateKey(privateKey: string): { iv: Uint8Array; ciphertext: Uint8Array } { + const json: unknown = JSON.parse(privateKey); + + if (typeof json !== 'object' || json === null) { + throw new Error('Invalid private key format'); + } + + if ('iv' in json && 'ciphertext' in json && typeof json.iv === 'string' && typeof json.ciphertext === 'string') { + return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext) }; + } + + if ('$binary' in json && typeof json.$binary === 'string') { + // Old format, just base64 string + const binary = Base64.decode(json.$binary); + const [iv, ciphertext] = splitVectorAndEncryptedData(binary, 16); + return { iv, ciphertext }; + } + + throw new Error('Invalid private key format'); + } + async decodePrivateKey(privateKey: string): Promise { const password = await this.requestPasswordAlert(); const masterKey = await this.getMasterKey(password); - - const [vector, cipherText] = splitVectorAndEncryptedData(EJSON.parse(privateKey), 12); + const { iv, ciphertext } = this.parsePrivateKey(privateKey); try { if (!masterKey) { throw new Error('Error getting master key'); } - const privKey = await decryptAesGcm(vector, masterKey, cipherText); + const privKey = await decryptAes(iv, masterKey, ciphertext); return toString(privKey); } catch (error) { this.setState('ENTER_PASSWORD'); @@ -681,29 +683,30 @@ class E2E extends Emitter { return decryptedMessageWithQuote; } - async decryptPinnedMessage(message: IMessage) { - const pinnedMessage = message?.attachments?.[0]?.text; + async decryptPinnedMessage(message: IE2EEPinnedMessage) { + return message; + // const pinnedMessage = message?.attachments?.[0]?.; - if (!pinnedMessage) { - return message; - } + // if (!pinnedMessage) { + // return message; + // } - const e2eRoom = await this.getInstanceByRoomId(message.rid); + // const e2eRoom = await this.getInstanceByRoomId(message.rid); - if (!e2eRoom) { - return message; - } + // if (!e2eRoom) { + // return message; + // } - const data = await e2eRoom.decrypt(pinnedMessage); + // const data = await e2eRoom.decrypt({ ciphertext: pinnedMessage }); - if (!data) { - return message; - } + // if (!data) { + // return message; + // } - const decryptedPinnedMessage = { ...message } as IMessage & { attachments: MessageAttachment[] }; - decryptedPinnedMessage.attachments[0].text = typeof data.msg === 'undefined' ? data.text : data.msg; + // const decryptedPinnedMessage = { ...message } as IMessage & { attachments: MessageAttachment[] }; + // decryptedPinnedMessage.attachments[0].text = typeof data.msg === 'undefined' ? data.text : data.msg; - return decryptedPinnedMessage; + // return decryptedPinnedMessage; } async decryptPendingMessages(): Promise { @@ -713,16 +716,21 @@ class E2E extends Emitter { ); } - async decryptSubscription(subscriptionId: ISubscription['_id']): Promise { - const e2eRoom = await this.getInstanceByRoomId(subscriptionId); - log('info', 'decryptSubscription', subscriptionId); + async decryptSubscription(subscription: SubscriptionWithRoom): Promise { + const span = log.span('decryptSubscription'); + const e2eRoom = await this.getInstanceByRoomId(subscription.rid); + span.info(subscription._id); await e2eRoom?.decryptSubscription(); } async decryptSubscriptions(): Promise { - Subscriptions.state - .filter((subscription) => Boolean(subscription.encrypted)) - .forEach((subscription) => this.decryptSubscription(subscription._id)); + const subscriptions = Subscriptions.state.filter((subscription) => !!subscription.encrypted); + + await Promise.all( + subscriptions.map(async (subscription) => { + await this.decryptSubscription(subscription); + }), + ); } openAlert(config: Omit): void { @@ -834,6 +842,7 @@ class E2E extends Emitter { } async initiateKeyDistribution() { + if (this.keyDistributionInterval) { return; } @@ -841,6 +850,7 @@ class E2E extends Emitter { const predicate = (record: IRoom) => Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== Meteor.userId())); + const span = log.span('initiateKeyDistribution'); const keyDistribution = async () => { const roomIds = Rooms.state.filter(predicate).map((room) => room._id); @@ -870,7 +880,7 @@ class E2E extends Emitter { try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return log('error', 'Error providing group key to users: ', error); + return span.error('provideUsersSuggestedGroupKeys', error); } }; diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index ddbcd0d419d1d..043015ddd99c9 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -110,7 +110,8 @@ export const useE2EEncryption = () => { } // Should encrypt this message. - return e2eRoom.encryptMessage(message); + const encryptedMessage = await e2eRoom.encryptMessage(message); + return encryptedMessage; }); listenersAttachedRef.current = true; diff --git a/packages/core-typings/src/IEncryptedContent.ts b/packages/core-typings/src/IEncryptedContent.ts new file mode 100644 index 0000000000000..ee3ce154a8d91 --- /dev/null +++ b/packages/core-typings/src/IEncryptedContent.ts @@ -0,0 +1,25 @@ +interface IEncryptedContent { + /** + * The encryption algorithm used. + * Currently supported algorithms are: + * - `rc.v1.aes-sha2`: Rocket.Chat E2E Encryption version 1, using AES encryption with SHA-256 hashing. + * - `rc.v2.aes-sha2`: Rocket.Chat E2E Encryption version 2, using AES encryption with SHA-256 hashing and improved key management. + */ + algorithm: string; + ciphertext: string; // base64-encoded encrypted subset JSON of IMessage +} + +interface IEncryptedContentV1 extends IEncryptedContent { + /** + * The encryption algorithm used. + */ + algorithm: 'rc.v1.aes-sha2' +} + +interface IEncryptedContentV2 extends IEncryptedContent { + algorithm: 'rc.v2.aes-sha2', + iv: string; // base64-encoded initialization vector + key_id: string; // ID of the key used to encrypt the message +} + +export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2; diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index e0ab71164b60f..e30b716dedbc1 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -4,6 +4,7 @@ import type Icons from '@rocket.chat/icons'; import type { Root } from '@rocket.chat/message-parser'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; +import type { EncryptedContent } from '../IEncryptedContent'; import type { ILivechatPriority } from '../ILivechatPriority'; import type { ILivechatVisitor } from '../ILivechatVisitor'; import type { IOmnichannelServiceLevelAgreements } from '../IOmnichannelServiceLevelAgreements'; @@ -231,15 +232,7 @@ export interface IMessage extends IRocketChatRecord { customFields?: IMessageCustomFields; - content?: { - algorithm: 'rc.v1.aes-sha2', - ciphertext: string; // Encrypted subset JSON of IMessage - } | { - algorithm: 'rc.v2.aes-sha2', - iv: string; // base64-encoded initialization vector - ciphertext: string; // base64-encoded encrypted subset JSON of IMessage - key_id: string; // ID of the key used to encrypt the message - }; + content?: EncryptedContent; } export interface ISystemMessage extends IMessage { @@ -377,6 +370,7 @@ export const isVoipMessage = (message: IMessage): message is IVoipMessage => 'vo export type IE2EEMessage = IMessage & { t: 'e2e'; e2e: 'pending' | 'done'; + content: EncryptedContent; }; export type IE2EEPinnedMessage = IMessage & { diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 8bcabee2fa8de..2d85a6dac3986 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -1,3 +1,4 @@ +import type { EncryptedContent } from './IEncryptedContent'; import type { IUser } from './IUser'; export interface IUpload { @@ -48,10 +49,7 @@ export interface IUpload { Webdav?: { path: string; }; - content?: { - algorithm: string; // 'rc.v1.aes-sha2' - ciphertext: string; // Encrypted subset JSON of IUpload - }; + content?: EncryptedContent; encryption?: { iv: string; key: JsonWebKey; @@ -64,10 +62,7 @@ export interface IUpload { export type IUploadWithUser = IUpload & { user?: Pick }; export type IE2EEUpload = IUpload & { - content: { - algorithm: string; // 'rc.v1.aes-sha2' - ciphertext: string; // Encrypted subset JSON of IUpload - }; + content: EncryptedContent; }; export const isE2EEUpload = (upload: IUpload): upload is IE2EEUpload => Boolean(upload?.content?.ciphertext && upload?.content?.algorithm); From a515b9c37548e5fc5d0cc0613f8d3f54e96a90cc Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 13 Sep 2025 13:35:53 -0300 Subject: [PATCH 157/251] simplify logger --- apps/meteor/client/lib/e2ee/logger.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 06738e08dff6b..34d8a262a07a7 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -7,14 +7,9 @@ const styles: Record = { }; class Logger { - static instances: Map> = new Map(); - title: string; constructor(title: string) { - if (Logger.instances.has(title)) { - throw new Error(`Logger with title "${title}" already exists.`); - } this.title = title; } From 5fe1f6ead244b8791fa582877ffba713d0de0ddd Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 13 Sep 2025 19:42:50 -0300 Subject: [PATCH 158/251] simplify decrypt --- apps/meteor/client/lib/e2ee/logger.ts | 25 +++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 98 +++++++++---------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 +- 3 files changed, 67 insertions(+), 60 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 34d8a262a07a7..fe0752887b5a4 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -14,7 +14,7 @@ class Logger { } span(label: string) { - return new Span(this, label); + return new Span(new WeakRef(this), label); } } @@ -23,19 +23,30 @@ class Span { label: string; - constructor(logger: Logger, label: string) { - this.logger = new WeakRef(logger); + attributes = new Map(); + + constructor(logger: WeakRef, label: string) { + this.logger = logger; this.label = label; } private log(level: LogLevel, message: string, ...params: unknown[]) { - console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]`, styles[level]); - console.trace(message); - if (params.length) { - console.log(...params); + console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'color: gray; font-weight: normal;', ...params); + if (this.attributes.size > 0) { + console.table(Object.fromEntries(this.attributes)); } + console.trace(); console.groupEnd(); } + + set(key: string, value: unknown) { + if (this.attributes.has(key)) { + this.attributes.get(key)?.push(value); + return this; + } + this.attributes.set(key, [value]); + return this; + } info(message: string, ...params: unknown[]) { this.log('info', message, ...params); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 0a71d59ec5061..425671b7b7bac 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -86,7 +86,7 @@ export class E2ERoom extends Emitter { roomKeyId: string | undefined; - groupSessionKey: CryptoKey | undefined; + groupSessionKey: CryptoKey | null = null; oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; @@ -423,6 +423,7 @@ export class E2ERoom extends Emitter { } async createGroupKey() { + await this.createNewGroupKey(); await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); @@ -439,6 +440,7 @@ export class E2ERoom extends Emitter { async resetRoomKey() { const span = log.span('resetRoomKey'); + if (!e2e.publicKey) { span.error('Cannot reset room key', 'No public key found.', e2e); return; @@ -461,7 +463,7 @@ export class E2ERoom extends Emitter { } onRoomKeyReset(keyID: string) { - const span = log.span('onRoomKeyReset'); + const span = log.span('onRoomKeyReset').set('key_id', keyID); if (this.keyID === keyID) { span.warn('Key ID matches current key, nothing to do'); @@ -471,7 +473,7 @@ export class E2ERoom extends Emitter { span.info('Room key has been reset', { oldKeyID: this.keyID, newKeyID: keyID }); this.setState('WAITING_KEYS'); this.keyID = keyID; - this.groupSessionKey = undefined; + this.groupSessionKey = null; this.sessionKeyExportedString = undefined; this.sessionKeyExported = undefined; this.oldKeys = undefined; @@ -720,14 +722,6 @@ export class E2ERoom extends Emitter { }; } - async doDecrypt(iv: Uint8Array, key: CryptoKey, ciphertext: Uint8Array): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date }> { - const span = log.span('doDecrypt'); - const result = await decryptAes(iv.buffer, key, ciphertext.buffer); - const parsed = EJSON.parse(new TextDecoder('UTF-8').decode(result)); - span.info('decrypted', parsed); - return parsed; - } - // Parses the message to extract the keyID and cipherText parse(payload: string | Required['content']): { key_id: string; @@ -752,50 +746,54 @@ export class E2ERoom extends Emitter { }; } - async decrypt(message: Required['content']): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { - const span = log.span('decrypt'); - const payload = this.parse(message); - let oldKey = null; - if (payload.key_id !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === payload.key_id); - // Messages already contain a keyID stored with them - // That means that if we cannot find a keyID for the key the message has preppended to - // The message is indecipherable. - // In these cases, we'll give a last shot using the current session key, which may not work - // but will be enough to help with some mobile issues. - if (!oldRoomKey) { - try { - if (!this.groupSessionKey) { - throw new Error('No group session key found.'); - } - const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - span.warn( - 'Message keyID does not match any known keys, but was able to decrypt with current session key. This may be a mobile client issue.', - { message: result }, - ); - return result; - } catch (error) { - span.error('Error decrypting message: ', error, message); - return { msg: t('E2E_indecipherable') }; - } + async decrypt( + message: Required['content'], + ): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { + const span = log.span('decrypt').set('rid', this.roomId); + const { key_id, iv, ciphertext } = this.parse(message); + span.set('key_id', key_id); + span.set('iv', iv.toString()); + span.set('ciphertext', ciphertext.toString()); + + let key; + if (key_id !== this.keyID) { + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === key_id); + if (oldRoomKey) { + key = oldRoomKey.E2EKey; + } else if (this.groupSessionKey) { + key = this.groupSessionKey; + span.warn('No matching old key found, using current group key'); + } else { + span.error('No matching key found'); + } + } else { + if (!this.groupSessionKey) { + span.error('No group session key found'); } - oldKey = oldRoomKey.E2EKey; + key = this.groupSessionKey; } - try { - if (oldKey) { - const result = await this.doDecrypt(payload.iv, oldKey, payload.ciphertext); - return result; - } - if (!this.groupSessionKey) { - throw new Error('No group session key found.'); + let ret; + + if (!key) { + span.error('No decryption key found.'); + ret = { msg: t('E2E_indecipherable') }; + } else { + span.set('algorithm', key.algorithm.name); + span.set('extractable', key.extractable); + span.set('type', key.type); + span.set('usages', key.usages.toString()); + try { + const result = await decryptAes(iv.buffer, key, ciphertext.buffer); + ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); + span.info('decrypted', ret); + } catch (error) { + span.error('', error); + ret = { msg: t('E2E_Key_Error') }; } - const result = await this.doDecrypt(payload.iv, this.groupSessionKey, payload.ciphertext); - return result; - } catch (error) { - span.error('decrypting', error); - return { msg: t('E2E_Key_Error') }; } + + return ret; } provideKeyToUser(keyId: string) { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 6e467cdef124e..4605cafba367b 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -176,7 +176,6 @@ class E2E extends Emitter { this.unsubscribeFromSubscriptions?.(); this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe(async (state) => { - const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey); const subscribed = new Set(subscriptions.map((sub) => sub.rid)); @@ -198,7 +197,7 @@ class E2E extends Emitter { } setState(nextState: E2EEState) { - const span = log.span('setState'); + const span = log.span('setState').set('prevState', this.state).set('nextState', nextState); const prevState = this.state; this.state = nextState; @@ -842,7 +841,6 @@ class E2E extends Emitter { } async initiateKeyDistribution() { - if (this.keyDistributionInterval) { return; } From c04d3eb55ea73e1227d6e65f7b411baa6a0e0627 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 14 Sep 2025 10:56:18 -0300 Subject: [PATCH 159/251] rename key_id -> kid --- apps/meteor/client/lib/e2ee/logger.ts | 27 ++-- .../client/lib/e2ee/rocketchat.e2e.room.ts | 121 +++++++++--------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 25 ++-- .../core-typings/src/IEncryptedContent.ts | 6 +- 4 files changed, 92 insertions(+), 87 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index fe0752887b5a4..0d19140b3991a 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -30,11 +30,13 @@ class Span { this.label = label; } - private log(level: LogLevel, message: string, ...params: unknown[]) { - console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'color: gray; font-weight: normal;', ...params); - if (this.attributes.size > 0) { - console.table(Object.fromEntries(this.attributes)); - } + private log(level: LogLevel, message: string) { + console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'color: gray; font-weight: normal;'); + + this.attributes.forEach((values, key) => { + console.log(`%c${key}:`, 'font-weight: bold;', ...values); + }); + console.trace(); console.groupEnd(); } @@ -48,16 +50,19 @@ class Span { return this; } - info(message: string, ...params: unknown[]) { - this.log('info', message, ...params); + info(message: string) { + this.log('info', message); } - warn(message: string, ...params: unknown[]) { - this.log('warn', message, ...params); + warn(message: string) { + this.log('warn', message); } - error(message: string, ...params: unknown[]) { - this.log('error', message, ...params); + error(message: string, error?: unknown) { + if (error) { + this.set('error', error); + } + this.log('error', message); } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 425671b7b7bac..118cc95772a0a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -258,9 +258,9 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { + span.set('error', e); span.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e, ); keys.push({ ...key, E2EKey: null }); } @@ -270,7 +270,7 @@ export class E2ERoom extends Emitter { } async exportOldRoomKeys(oldKeys: ISubscription['oldRoomKeys']) { - const span = log.span('exportOldRoomKeys'); + const span = log.span('exportOldRoomKeys').set('oldKeys', oldKeys); if (!oldKeys || oldKeys.length === 0) { span.info('Nothing to do'); return; @@ -289,9 +289,9 @@ export class E2ERoom extends Emitter { E2EKey: k, }); } catch (e) { + span.set('error', e); span.error( - `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, - e, + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping.`, ); } } @@ -329,12 +329,14 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - span.error('Error fetching group key: ', error); + span.set('error', error); + span.error('Error fetching group key'); return; } try { const room = Rooms.state.get(this.roomId); + span.set('room', room); if (!room?.e2eKeyId) { this.setState('CREATING_KEYS'); await this.createGroupKey(); @@ -343,10 +345,11 @@ export class E2ERoom extends Emitter { } this.setState('WAITING_KEYS'); - span.info('Requesting room key', room.e2eKeyId); + span.info('Requesting room key'); sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { - span.error('Error during handshake: ', error); + span.set('error', error); + span.error('Error during handshake'); this.setState('ERROR'); } } @@ -375,14 +378,18 @@ export class E2ERoom extends Emitter { const span = log.span('importGroupKey'); // Get existing group key const keyID = groupKey.slice(0, 36); - groupKey = groupKey.slice(36); - const decodedGroupKey = Base64.decode(groupKey); + + span.set('kid', keyID); if (this.keyID === keyID && this.groupSessionKey) { span.info('Key already imported'); return true; } + groupKey = groupKey.slice(36); + const decodedGroupKey = Base64.decode(groupKey); + span.set('group_key', groupKey); + // Decrypt obtained encrypted session key try { if (!e2e.privateKey) { @@ -391,7 +398,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { - span.error('Error decrypting group key: ', { error, groupKey, keyID, decodedGroupKey }); + span.set('error', error).error('Error decrypting group key'); return false; } @@ -407,7 +414,7 @@ export class E2ERoom extends Emitter { // Key has been obtained. E2E is now in session. this.groupSessionKey = key; } catch (error) { - span.error('Error importing group key: ', error); + span.set('error', error).error('Error importing group key'); return false; } @@ -423,7 +430,6 @@ export class E2ERoom extends Emitter { } async createGroupKey() { - await this.createNewGroupKey(); await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); @@ -442,7 +448,7 @@ export class E2ERoom extends Emitter { const span = log.span('resetRoomKey'); if (!e2e.publicKey) { - span.error('Cannot reset room key', 'No public key found.', e2e); + span.error('No public key found'); return; } @@ -453,11 +459,11 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - span.info('Room key reset successfully', e2eNewKeys); + span.set('kid', this.keyID).set('key', e2eNewKeys.e2eKey).info('Room key reset successfully'); return e2eNewKeys; } catch (error) { - span.error('resetRoomKey failed', error); + span.set('error', error).error('Error resetting room key'); throw error; } } @@ -470,7 +476,7 @@ export class E2ERoom extends Emitter { return; } - span.info('Room key has been reset', { oldKeyID: this.keyID, newKeyID: keyID }); + span.set('new_key_id', keyID).info('Room key has been reset'); this.setState('WAITING_KEYS'); this.keyID = keyID; this.groupSessionKey = null; @@ -516,7 +522,7 @@ export class E2ERoom extends Emitter { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { - return span.error('Error getting room users: ', error); + return span.set('error', error).error('Error getting room users'); } } @@ -531,7 +537,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return span.error('Error importing user key', error); + return span.set('error', error).error('Error importing user key'); } try { @@ -547,7 +553,7 @@ export class E2ERoom extends Emitter { } return keys; } catch (error) { - return span.error('failed to encrypt old keys for participant', error); + return span.set('error', error).error('failed to encrypt old keys for participant'); } } @@ -557,7 +563,7 @@ export class E2ERoom extends Emitter { try { userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); } catch (error) { - return span.error('error', 'importing user key', error); + return span.set('error', error).error('Error importing user key'); } // const vector = crypto.getRandomValues(new Uint8Array(16)); @@ -567,7 +573,7 @@ export class E2ERoom extends Emitter { const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); return encryptedUserKeyToString; } catch (error) { - return span.error('Error encrypting user key: ', error); + return span.set('error', error).error('Error encrypting user key'); } } @@ -588,7 +594,7 @@ export class E2ERoom extends Emitter { try { result = await encryptAESCTR(vector, key, fileArrayBuffer); } catch (error) { - return span.error('Error encrypting group key: ', error); + return span.set('error', error).error('Error encrypting group key'); } const exportedKey = await window.crypto.subtle.exportKey('jwk', key); @@ -625,14 +631,14 @@ export class E2ERoom extends Emitter { } const result = await encryptAesGcm(vector.buffer, this.groupSessionKey, data); const ciphertext = { - key_id: this.keyID, + kid: this.keyID, iv: Base64.encode(vector), ciphertext: Base64.encode(new Uint8Array(result)), }; - span.info('encrypted', ciphertext); + span.set('ciphertext', ciphertext).info('message encrypted'); return ciphertext; } catch (error) { - span.error('Error encrypting message: ', error); + span.set('error', error).error('Error encrypting message'); throw error; } } @@ -694,17 +700,8 @@ export class E2ERoom extends Emitter { return data; } - const { content } = data; - - if (content.algorithm === 'rc.v1.aes-sha2') { - const decrypted = await this.decrypt(content); - Object.assign(data, decrypted); - } - - if (content.algorithm === 'rc.v2.aes-sha2') { - const decrypted = await this.decrypt(content); - Object.assign(data, decrypted); - } + const decrypted = await this.decrypt(data.content); + Object.assign(data, decrypted); return data; } @@ -724,23 +721,23 @@ export class E2ERoom extends Emitter { // Parses the message to extract the keyID and cipherText parse(payload: string | Required['content']): { - key_id: string; + kid: string; iv: Uint8Array; ciphertext: Uint8Array; } { - // v2: {"key_id":"...", "iv": "...", "ciphertext":"..."} + // v2: {"kid":"...", "iv": "...", "ciphertext":"..."} if (typeof payload !== 'string' && payload.algorithm === 'rc.v2.aes-sha2') { const iv = Base64.decode(payload.iv); const ciphertext = Base64.decode(payload.ciphertext); - return { key_id: payload.key_id, iv, ciphertext }; + return { kid: payload.kid, iv, ciphertext }; } - // v1: key_id + base64(vector + ciphertext) + // v1: kid + base64(vector + ciphertext) const message = typeof payload === 'string' ? payload : payload.ciphertext; - const key_id = message.slice(0, 12); + const kid = message.slice(0, 12); const [iv, ciphertext] = splitVectorAndEncryptedData(Base64.decode(message.slice(12))); return { - key_id, + kid, iv, ciphertext, }; @@ -750,14 +747,14 @@ export class E2ERoom extends Emitter { message: Required['content'], ): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { const span = log.span('decrypt').set('rid', this.roomId); - const { key_id, iv, ciphertext } = this.parse(message); - span.set('key_id', key_id); + const { kid, iv, ciphertext } = this.parse(message); + span.set('kid', kid); span.set('iv', iv.toString()); span.set('ciphertext', ciphertext.toString()); let key; - if (key_id !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === key_id); + if (kid !== this.keyID) { + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid); if (oldRoomKey) { key = oldRoomKey.E2EKey; } else if (this.groupSessionKey) { @@ -773,27 +770,23 @@ export class E2ERoom extends Emitter { key = this.groupSessionKey; } - let ret; - if (!key) { span.error('No decryption key found.'); - ret = { msg: t('E2E_indecipherable') }; - } else { - span.set('algorithm', key.algorithm.name); - span.set('extractable', key.extractable); - span.set('type', key.type); - span.set('usages', key.usages.toString()); - try { - const result = await decryptAes(iv.buffer, key, ciphertext.buffer); - ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); - span.info('decrypted', ret); - } catch (error) { - span.error('', error); - ret = { msg: t('E2E_Key_Error') }; - } + return { msg: t('E2E_indecipherable') }; + } + span.set('algorithm', key.algorithm.name); + span.set('extractable', key.extractable); + span.set('type', key.type); + span.set('usages', key.usages.toString()); + try { + const result = await decryptAes(iv.buffer, key, ciphertext.buffer); + const ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); + span.set('decrypted', ret).info('decrypted'); + return ret; + } catch (error) { + span.set('error', error).error('Error decrypting message'); + return { msg: t('E2E_Key_Error') }; } - - return ret; } provideKeyToUser(keyId: string) { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 4605cafba367b..81316df051bbe 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -125,8 +125,10 @@ class E2E extends Emitter { } async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise { - const span = log.span('onSubscriptionChanged'); - span.info('subscriptionChanged', sub.lastMessage); + const span = log.span('onSubscriptionChanged') + .set('subscription_id', sub._id) + .set('room_id', sub.rid) + .set('encrypted', sub.encrypted); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -134,6 +136,7 @@ class E2E extends Emitter { const e2eRoom = await this.getInstanceByRoomId(sub.rid); if (!e2eRoom) { + span.warn('no e2eRoom found'); return; } @@ -142,7 +145,7 @@ class E2E extends Emitter { await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - span.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + span.warn('rejected'); await this.rejectSuggestedKey(sub.rid); } } @@ -183,7 +186,7 @@ class E2E extends Emitter { const excess = instatiated.difference(subscribed); if (excess.size) { - span.info('Unsubscribing from excess instances', excess); + span.info('Unsubscribing from excess instances'); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } @@ -216,13 +219,16 @@ class E2E extends Emitter { .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) .map(async (sub) => { const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); + span + .set('subscription_id', sub._id) + .set('room_id', sub.rid); if (!e2eRoom) { return; } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { - span.info('importedE2ESuggestedKey', sub.E2ESuggestedKey); + span.info('importedE2ESuggestedKey'); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { @@ -233,6 +239,7 @@ class E2E extends Emitter { sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); }), ); + span.info('handledAsyncE2ESuggestedKey'); } private waitForRoom(rid: IRoom['_id']): Promise { @@ -475,7 +482,7 @@ class E2E extends Emitter { this.privateKey = key.privateKey; } catch (error) { this.setState('ERROR'); - return span.error('Error generating key: ', error); + return span.set('error', error).error('Error generating key'); } try { @@ -485,7 +492,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { this.setState('ERROR'); - return span.error('Error exporting public key: ', error); + return span.set('error', error).error('Error exporting public key'); } try { @@ -494,7 +501,7 @@ class E2E extends Emitter { Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { this.setState('ERROR'); - return span.error('Error exporting private key: ', error); + return span.set('error', error).error('Error exporting private key'); } await this.requestSubscriptionKeys(); @@ -878,7 +885,7 @@ class E2E extends Emitter { try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return span.error('provideUsersSuggestedGroupKeys', error); + return span.set('error', error).error('provideUsersSuggestedGroupKeys'); } }; diff --git a/packages/core-typings/src/IEncryptedContent.ts b/packages/core-typings/src/IEncryptedContent.ts index ee3ce154a8d91..9cdde121fe9d8 100644 --- a/packages/core-typings/src/IEncryptedContent.ts +++ b/packages/core-typings/src/IEncryptedContent.ts @@ -13,13 +13,13 @@ interface IEncryptedContentV1 extends IEncryptedContent { /** * The encryption algorithm used. */ - algorithm: 'rc.v1.aes-sha2' + algorithm: 'rc.v1.aes-sha2'; } interface IEncryptedContentV2 extends IEncryptedContent { - algorithm: 'rc.v2.aes-sha2', + algorithm: 'rc.v2.aes-sha2'; iv: string; // base64-encoded initialization vector - key_id: string; // ID of the key used to encrypt the message + kid: string; // ID of the key used to encrypt the message } export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2; From d46de6f8ec3d0db6008e60d24e9ea1a6f44bb2c4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 14 Sep 2025 14:53:07 -0300 Subject: [PATCH 160/251] fix passphrase entropy --- apps/meteor/client/lib/e2ee/helper.ts | 46 +- apps/meteor/client/lib/e2ee/logger.ts | 63 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 34 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 13 +- apps/meteor/client/lib/e2ee/wordList.ts | 4102 +++++++++-------- 5 files changed, 2153 insertions(+), 2105 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 843e87b358d0a..374f3eed5a81d 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -33,7 +33,10 @@ export function joinVectorAndEncryptedData(vector: Uint8Array, encr return output; } -export function splitVectorAndEncryptedData(cipherText: Uint8Array, ivLength: 12 | 16 = 16): [Uint8Array, Uint8Array] { +export function splitVectorAndEncryptedData( + cipherText: Uint8Array, + ivLength: 12 | 16 = 16, +): [Uint8Array, Uint8Array] { const vector = cipherText.slice(0, ivLength); const encryptedData = cipherText.slice(ivLength); @@ -74,7 +77,7 @@ export async function decryptAesGcm(iv: BufferSource, key: CryptoKey, data: Buff export function decryptAes(iv: BufferSource, key: CryptoKey, data: BufferSource) { if (key.algorithm.name === 'AES-GCM') { return decryptAesGcm(iv, key, data); - } + } return decryptAesCbc(iv, key, data); } @@ -176,11 +179,40 @@ export function readFileAsArrayBuffer(file: File) { }); } -export const generateMnemonicPhrase = async (length: number): Promise => { - const { default: wordList} = await import('./wordList'); - const randomBuffer = crypto.getRandomValues(new Uint8Array(length)); - return Array.from(randomBuffer, (value) => wordList[value % wordList.length]).join(' '); -}; +/** + * Generates 12 uniformly random words from the word list. + * + * @remarks + * Uses rejection sampling to ensure uniform distribution. + * {@link https://en.wikipedia.org/wiki/Rejection_sampling} + * + * @returns A space-separated passphrase. + */ +export async function generatePassphrase() { + const { wordlist } = await import('./wordList'); + + // Number of words in the passphrase + const WORD_COUNT = 12; + // We use 32-bit random numbers, so the maximum value is 2^32 - 1 + const MAX_UINT32 = 0xffffffff; + + const range = wordlist.length; + const rejectionThreshold = Math.floor(MAX_UINT32 / range) * range; + + const words: string[] = []; + const buf = new Uint32Array(1); + + for (let i = 0; i < WORD_COUNT; i++) { + let v: number; + do { + crypto.getRandomValues(buf); + v = buf[0]; + } while (v >= rejectionThreshold); + words.push(wordlist[v % range]); + } + + return words.join(' '); +} export async function createSha256HashFromText(data: string) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 0d19140b3991a..a15a9fc5323a4 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ type LogLevel = 'info' | 'warn' | 'error'; const styles: Record = { @@ -6,6 +7,25 @@ const styles: Record = { error: 'color: white; background-color: red; font-weight: bold;', }; + +interface ConsoleWithContext extends Console { + context(label: string): Console; +} + +const console = ((): ConsoleWithContext => { + if ('context' in globalThis.console) { + return globalThis.console as ConsoleWithContext; + } + + return { + ...globalThis.console, + + context(_label: string) { + return globalThis.console + } + }; +})(); + class Logger { title: string; @@ -14,39 +34,46 @@ class Logger { } span(label: string) { - return new Span(new WeakRef(this), label); + return new Span(new WeakRef(this), label, console.context(this.title)); + } + + instrument(label: string, fn: () => void) { + const span = this.span(label); + try { + span.info('start'); + fn(); + span.info('end'); + } catch (error) { + span.error('error', error); + throw error; + } } } class Span { - logger: WeakRef; + private logger: WeakRef; + + private label: string; - label: string; + private attributes = new Map(); - attributes = new Map(); + private console: Console; - constructor(logger: WeakRef, label: string) { + constructor(logger: WeakRef, label: string, console: Console) { this.logger = logger; this.label = label; + this.console = console; } private log(level: LogLevel, message: string) { - console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'color: gray; font-weight: normal;'); - - this.attributes.forEach((values, key) => { - console.log(`%c${key}:`, 'font-weight: bold;', ...values); - }); - - console.trace(); - console.groupEnd(); + this.console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'font-weight: normal;'); + this.console.dir(Object.fromEntries(this.attributes.entries()), { }); + this.console.groupEnd(); + this.console.trace(); } set(key: string, value: unknown) { - if (this.attributes.has(key)) { - this.attributes.get(key)?.push(value); - return this; - } - this.attributes.set(key, [value]); + this.attributes.set(key, value); return this; } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 118cc95772a0a..3922c63c90c87 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -35,7 +35,7 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; // import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; -const log = createLogger('E2ERoom'); +const log = createLogger('Room'); const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -747,28 +747,11 @@ export class E2ERoom extends Emitter { message: Required['content'], ): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { const span = log.span('decrypt').set('rid', this.roomId); - const { kid, iv, ciphertext } = this.parse(message); - span.set('kid', kid); - span.set('iv', iv.toString()); - span.set('ciphertext', ciphertext.toString()); - - let key; - if (kid !== this.keyID) { - const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid); - if (oldRoomKey) { - key = oldRoomKey.E2EKey; - } else if (this.groupSessionKey) { - key = this.groupSessionKey; - span.warn('No matching old key found, using current group key'); - } else { - span.error('No matching key found'); - } - } else { - if (!this.groupSessionKey) { - span.error('No group session key found'); - } - key = this.groupSessionKey; - } + const payload = this.parse(message); + span.set('payload', payload); + const { kid, iv, ciphertext } = payload; + + const key = this.retrieveDecryptionKey(kid); if (!key) { span.error('No decryption key found.'); @@ -789,6 +772,11 @@ export class E2ERoom extends Emitter { } } + private retrieveDecryptionKey(kid: string): CryptoKey | null { + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid); + return oldRoomKey?.E2EKey ?? this.groupSessionKey; + } + provideKeyToUser(keyId: string) { if (this.keyID !== keyId) { return; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 81316df051bbe..7230dade5ae74 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -25,7 +25,7 @@ import { importRSAKey, importRawKey, deriveKey, - generateMnemonicPhrase, + generatePassphrase, decryptAes, } from './helper'; import { createLogger } from './logger'; @@ -125,10 +125,7 @@ class E2E extends Emitter { } async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise { - const span = log.span('onSubscriptionChanged') - .set('subscription_id', sub._id) - .set('room_id', sub.rid) - .set('encrypted', sub.encrypted); + const span = log.span('onSubscriptionChanged').set('subscription_id', sub._id).set('room_id', sub.rid).set('encrypted', sub.encrypted); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; @@ -219,9 +216,7 @@ class E2E extends Emitter { .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) .map(async (sub) => { const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); - span - .set('subscription_id', sub._id) - .set('room_id', sub.rid); + span.set('subscription_id', sub._id).set('room_id', sub.rid); if (!e2eRoom) { return; @@ -512,7 +507,7 @@ class E2E extends Emitter { } async createRandomPassword(): Promise { - const randomPassword = await generateMnemonicPhrase(12); + const randomPassword = await generatePassphrase(); Accounts.storageLocation.setItem('e2e.randomPassword', randomPassword); return randomPassword; } diff --git a/apps/meteor/client/lib/e2ee/wordList.ts b/apps/meteor/client/lib/e2ee/wordList.ts index d6c205d9e43a0..eca3273da3466 100644 --- a/apps/meteor/client/lib/e2ee/wordList.ts +++ b/apps/meteor/client/lib/e2ee/wordList.ts @@ -1,2048 +1,2054 @@ -export default `abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo`.split('\n'); +/** + * The BIP-39 English word list. + * {@link https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt} + */ +export const wordlist = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo', +]; \ No newline at end of file From a64bc5b4980087f02f2d976f3784482adc9fcb91 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 11:45:41 -0300 Subject: [PATCH 161/251] revert package.json changes --- apps/meteor/ee/server/services/package.json | 2 +- apps/meteor/package.json | 11 ++- ee/apps/account-service/package.json | 2 +- ee/apps/authorization-service/package.json | 2 +- ee/apps/ddp-streamer/package.json | 2 +- ee/apps/omnichannel-transcript/package.json | 2 +- ee/apps/presence-service/package.json | 2 +- ee/apps/queue-worker/package.json | 2 +- ee/apps/stream-hub-service/package.json | 2 +- ee/packages/network-broker/package.json | 2 +- ee/packages/omnichannel-services/package.json | 2 +- ee/packages/presence/package.json | 2 +- packages/apps-engine/package.json | 2 +- packages/message-parser/package.json | 2 +- packages/peggy-loader/package.json | 2 +- packages/release-action/package.json | 2 +- packages/release-changelog/package.json | 2 +- packages/ui-composer/package.json | 1 - packages/ui-voip/package.json | 2 +- yarn.lock | 78 +++++++++---------- 20 files changed, 60 insertions(+), 64 deletions(-) diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index e9189142f67f5..fbdb5558a83f8 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -54,7 +54,7 @@ "@types/ejson": "^2.2.2", "@types/express": "^4.17.23", "@types/fibers": "^3.1.4", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@types/ws": "^8.5.13", "npm-run-all": "^4.1.5", "pino-pretty": "^7.6.1", diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 30e6ef223a437..a1e42b9e2ea6f 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -72,7 +72,7 @@ "@babel/preset-react": "~7.25.9", "@babel/register": "~7.25.9", "@faker-js/faker": "~8.0.2", - "@playwright/test": "~1.55.0", + "@playwright/test": "^1.52.0", "@rocket.chat/desktop-api": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", @@ -115,7 +115,6 @@ "@types/i18next-sprintf-postprocessor": "^0.2.3", "@types/imap": "^0.8.42", "@types/jest": "~30.0.0", - "@types/js-yaml": "~4.0.9", "@types/jsdom": "^21.1.7", "@types/jsdom-global": "^3.0.7", "@types/jsrsasign": "^10.5.15", @@ -133,7 +132,7 @@ "@types/meteor-collection-hooks": "^0.8.9", "@types/mkdirp": "^1.0.2", "@types/mocha": "github:whitecolor/mocha-types", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@types/node-rsa": "^1.1.4", "@types/nodemailer": "^6.4.17", "@types/oauth2-server": "^3.0.18", @@ -180,7 +179,7 @@ "eslint-plugin-anti-trojan-source": "~1.1.1", "eslint-plugin-import": "~2.31.0", "eslint-plugin-no-floating-promise": "~2.0.0", - "eslint-plugin-playwright": "~2.2.2", + "eslint-plugin-playwright": "~2.2.0", "eslint-plugin-prettier": "~5.2.6", "eslint-plugin-react": "~7.37.5", "eslint-plugin-react-hooks": "~5.0.0", @@ -194,8 +193,8 @@ "nyc": "^17.1.0", "outdent": "~0.8.0", "pino-pretty": "^7.6.1", - "playwright-core": "~1.55.0", - "playwright-qase-reporter": "^2.1.5", + "playwright-core": "~1.52.0", + "playwright-qase-reporter": "^2.1.3", "postcss": "~8.4.49", "postcss-custom-properties": "^14.0.6", "postcss-easy-import": "^4.0.0", diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index 50dd1768577e4..33c0bb1332c22 100644 --- a/ee/apps/account-service/package.json +++ b/ee/apps/account-service/package.json @@ -26,7 +26,7 @@ "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tools": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "bcrypt": "^5.1.1", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index 2f46727e26dfc..1c546a98c1aea 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -25,7 +25,7 @@ "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index c2d04fe9d4bbd..902ab9fb776f2 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -48,7 +48,7 @@ "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", "@types/ejson": "^2.2.2", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@types/polka": "^0.5.7", "@types/prometheus-gc-stats": "^0.6.4", "@types/underscore": "^1.13.0", diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index f6156bbdbdf2c..3ed4359012396 100644 --- a/ee/apps/omnichannel-transcript/package.json +++ b/ee/apps/omnichannel-transcript/package.json @@ -28,7 +28,7 @@ "@rocket.chat/pdf-worker": "workspace:^", "@rocket.chat/tools": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", "event-loop-stats": "^1.4.1", diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index bd9dcdbd180af..4c94881aea613 100644 --- a/ee/apps/presence-service/package.json +++ b/ee/apps/presence-service/package.json @@ -25,7 +25,7 @@ "@rocket.chat/presence": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index 3b8705c45f3e3..a552be1e58ceb 100644 --- a/ee/apps/queue-worker/package.json +++ b/ee/apps/queue-worker/package.json @@ -24,7 +24,7 @@ "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", "event-loop-stats": "^1.4.1", diff --git a/ee/apps/stream-hub-service/package.json b/ee/apps/stream-hub-service/package.json index 4a9e1445e3287..6771383b2cfe9 100644 --- a/ee/apps/stream-hub-service/package.json +++ b/ee/apps/stream-hub-service/package.json @@ -24,7 +24,7 @@ "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tracing": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "ejson": "^2.2.3", "event-loop-stats": "^1.4.1", "eventemitter3": "^5.0.1", diff --git a/ee/packages/network-broker/package.json b/ee/packages/network-broker/package.json index 6a1603e207811..8e9bbd8203846 100644 --- a/ee/packages/network-broker/package.json +++ b/ee/packages/network-broker/package.json @@ -7,7 +7,7 @@ "@rocket.chat/tsconfig": "workspace:*", "@types/chai": "~4.3.20", "@types/ejson": "^2.2.2", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@types/sinon": "^10.0.20", "chai": "^4.5.0", "eslint": "~8.45.0", diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 2710ff228b55f..12512d3873617 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -24,7 +24,7 @@ "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/string-helpers": "~0.31.25", "@rocket.chat/tools": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "date-fns": "~4.1.0", "ejson": "^2.2.3", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 4c7231b2e4a79..df4f005085318 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -9,7 +9,7 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "babel-jest": "~30.0.5", "eslint": "~8.45.0", "jest": "~30.0.5", diff --git a/packages/apps-engine/package.json b/packages/apps-engine/package.json index f99333896741c..8b81eeebf583f 100644 --- a/packages/apps-engine/package.json +++ b/packages/apps-engine/package.json @@ -75,7 +75,7 @@ "@types/adm-zip": "^0.5.7", "@types/debug": "^4.1.12", "@types/lodash.clonedeep": "^4.5.9", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@types/semver": "^7.5.8", "@types/stack-trace": "0.0.33", "@types/uuid": "~10.0.0", diff --git a/packages/message-parser/package.json b/packages/message-parser/package.json index dc8c6d0008914..7a48d3cdb46b6 100644 --- a/packages/message-parser/package.json +++ b/packages/message-parser/package.json @@ -56,7 +56,7 @@ "@rocket.chat/peggy-loader": "workspace:~", "@rocket.chat/prettier-config": "~0.31.25", "@types/jest": "~30.0.0", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "@typescript-eslint/parser": "~5.58.0", "babel-loader": "~9.2.1", "eslint": "~8.45.0", diff --git a/packages/peggy-loader/package.json b/packages/peggy-loader/package.json index c85c0e1a70c0d..dbf5663217a75 100644 --- a/packages/peggy-loader/package.json +++ b/packages/peggy-loader/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:~", "@rocket.chat/prettier-config": "~0.31.25", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "eslint": "~8.45.0", "npm-run-all": "^4.1.5", "peggy": "4.1.1", diff --git a/packages/release-action/package.json b/packages/release-action/package.json index 178f3a14387e8..88923590e47f1 100644 --- a/packages/release-action/package.json +++ b/packages/release-action/package.json @@ -10,7 +10,7 @@ "main": "dist/index.js", "packageManager": "yarn@4.9.3", "devDependencies": { - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "typescript": "~5.9.2" }, "dependencies": { diff --git a/packages/release-changelog/package.json b/packages/release-changelog/package.json index e3fea8d63e4d9..d53b0bb2cbbd4 100644 --- a/packages/release-changelog/package.json +++ b/packages/release-changelog/package.json @@ -10,7 +10,7 @@ "devDependencies": { "@changesets/types": "^6.0.0", "@rocket.chat/eslint-config": "workspace:^", - "@types/node": "~22.16.5", + "@types/node": "~22.16.1", "eslint": "~8.45.0", "typescript": "~5.9.2" }, diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index d3778e8282c41..11a95813b9e54 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -51,7 +51,6 @@ "react-dom": "~18.3.1", "react-virtuoso": "^4.12.0", "storybook": "^8.6.14", - "ts-node": "~10.9.2", "typescript": "~5.9.2", "webpack": "~5.99.9" }, diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index edca146d0041c..d19954461f971 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@babel/core": "~7.26.10", "@faker-js/faker": "~8.0.2", - "@playwright/test": "~1.55.0", + "@playwright/test": "^1.52.0", "@react-spectrum/test-utils": "~1.0.0-alpha.8", "@rocket.chat/css-in-js": "~0.31.25", "@rocket.chat/eslint-config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index ee1c1e57230d4..11a4c45c962b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4936,7 +4936,7 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:~1.55.0": +"@playwright/test@npm:^1.52.0": version: 1.55.0 resolution: "@playwright/test@npm:1.55.0" dependencies: @@ -6912,7 +6912,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/bcrypt": "npm:^5.0.2" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" bcrypt: "npm:^5.1.1" @@ -6990,7 +6990,7 @@ __metadata: "@types/adm-zip": "npm:^0.5.7" "@types/debug": "npm:^4.1.12" "@types/lodash.clonedeep": "npm:^4.5.9" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/semver": "npm:^7.5.8" "@types/stack-trace": "npm:0.0.33" "@types/uuid": "npm:~10.0.0" @@ -7048,7 +7048,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -7221,7 +7221,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@types/ejson": "npm:^2.2.2" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" "@types/underscore": "npm:^1.13.0" @@ -7837,7 +7837,7 @@ __metadata: "@rocket.chat/peggy-loader": "workspace:~" "@rocket.chat/prettier-config": "npm:~0.31.25" "@types/jest": "npm:~30.0.0" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@typescript-eslint/parser": "npm:~5.58.0" babel-loader: "npm:~9.2.1" eslint: "npm:~8.45.0" @@ -7900,7 +7900,7 @@ __metadata: "@opentelemetry/exporter-trace-otlp-grpc": "npm:^0.54.2" "@opentelemetry/sdk-node": "npm:^0.54.2" "@parse/node-apn": "npm:^6.3.0" - "@playwright/test": "npm:~1.55.0" + "@playwright/test": "npm:^1.52.0" "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@react-pdf/renderer": "npm:^3.4.5" "@rocket.chat/account-utils": "workspace:^" @@ -8015,7 +8015,6 @@ __metadata: "@types/i18next-sprintf-postprocessor": "npm:^0.2.3" "@types/imap": "npm:^0.8.42" "@types/jest": "npm:~30.0.0" - "@types/js-yaml": "npm:~4.0.9" "@types/jsdom": "npm:^21.1.7" "@types/jsdom-global": "npm:^3.0.7" "@types/jsrsasign": "npm:^10.5.15" @@ -8034,7 +8033,7 @@ __metadata: "@types/meteor-collection-hooks": "npm:^0.8.9" "@types/mkdirp": "npm:^1.0.2" "@types/mocha": "github:whitecolor/mocha-types" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/node-rsa": "npm:^1.1.4" "@types/nodemailer": "npm:^6.4.17" "@types/oauth2-server": "npm:^3.0.18" @@ -8119,7 +8118,7 @@ __metadata: eslint-plugin-anti-trojan-source: "npm:~1.1.1" eslint-plugin-import: "npm:~2.31.0" eslint-plugin-no-floating-promise: "npm:~2.0.0" - eslint-plugin-playwright: "npm:~2.2.2" + eslint-plugin-playwright: "npm:~2.2.0" eslint-plugin-prettier: "npm:~5.2.6" eslint-plugin-react: "npm:~7.37.5" eslint-plugin-react-hooks: "npm:~5.0.0" @@ -8198,8 +8197,8 @@ __metadata: path-to-regexp: "npm:^6.3.0" pino: "npm:^8.21.0" pino-pretty: "npm:^7.6.1" - playwright-core: "npm:~1.55.0" - playwright-qase-reporter: "npm:^2.1.5" + playwright-core: "npm:~1.52.0" + playwright-qase-reporter: "npm:^2.1.3" postcss: "npm:~8.4.49" postcss-custom-properties: "npm:^14.0.6" postcss-easy-import: "npm:^4.0.0" @@ -8370,7 +8369,7 @@ __metadata: "@rocket.chat/tsconfig": "workspace:*" "@types/chai": "npm:~4.3.20" "@types/ejson": "npm:^2.2.2" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/sinon": "npm:^10.0.20" chai: "npm:^4.5.0" ejson: "npm:^2.2.3" @@ -8441,7 +8440,7 @@ __metadata: "@rocket.chat/tools": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" date-fns: "npm:~4.1.0" ejson: "npm:^2.2.3" emoji-toolkit: "npm:^7.0.1" @@ -8478,7 +8477,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/i18next-sprintf-postprocessor": "npm:^0.2.3" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" "@types/react": "npm:~18.3.23" @@ -8593,7 +8592,7 @@ __metadata: dependencies: "@rocket.chat/eslint-config": "workspace:~" "@rocket.chat/prettier-config": "npm:~0.31.25" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" eslint: "npm:~8.45.0" npm-run-all: "npm:^4.1.5" peggy: "npm:4.1.1" @@ -8632,7 +8631,7 @@ __metadata: "@rocket.chat/string-helpers": "npm:~0.31.25" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -8664,7 +8663,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" babel-jest: "npm:~30.0.5" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" @@ -8697,7 +8696,7 @@ __metadata: "@rocket.chat/omnichannel-services": "workspace:^" "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -8746,7 +8745,7 @@ __metadata: "@octokit/core": "npm:^5.0.1" "@octokit/plugin-throttling": "npm:^6.1.0" "@rocket.chat/eslint-config": "workspace:^" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" eslint: "npm:~8.45.0" mdast-util-to-string: "npm:2.0.0" remark-parse: "npm:9.0.0" @@ -8763,7 +8762,7 @@ __metadata: dependencies: "@changesets/types": "npm:^6.0.0" "@rocket.chat/eslint-config": "workspace:^" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" dataloader: "npm:^2.2.3" eslint: "npm:~8.45.0" node-fetch: "npm:^2.7.0" @@ -8886,7 +8885,7 @@ __metadata: "@rocket.chat/tracing": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/bcrypt": "npm:^5.0.2" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/polka": "npm:^0.5.7" "@types/prometheus-gc-stats": "npm:^0.6.4" ejson: "npm:^2.2.3" @@ -9088,7 +9087,6 @@ __metadata: react-dom: "npm:~18.3.1" react-virtuoso: "npm:^4.12.0" storybook: "npm:^8.6.14" - ts-node: "npm:~10.9.2" typescript: "npm:~5.9.2" webpack: "npm:~5.99.9" peerDependencies: @@ -9257,7 +9255,7 @@ __metadata: dependencies: "@babel/core": "npm:~7.26.10" "@faker-js/faker": "npm:~8.0.2" - "@playwright/test": "npm:~1.55.0" + "@playwright/test": "npm:^1.52.0" "@react-spectrum/test-utils": "npm:~1.0.0-alpha.8" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" @@ -11682,7 +11680,7 @@ __metadata: languageName: node linkType: hard -"@types/js-yaml@npm:^4.0.9, @types/js-yaml@npm:~4.0.9": +"@types/js-yaml@npm:^4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6 @@ -12005,7 +12003,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:~22.16.1, @types/node@npm:~22.16.5": +"@types/node@npm:~22.16.1": version: 22.16.5 resolution: "@types/node@npm:22.16.5" dependencies: @@ -19297,7 +19295,7 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-playwright@npm:~2.2.2": +"eslint-plugin-playwright@npm:~2.2.0": version: 2.2.2 resolution: "eslint-plugin-playwright@npm:2.2.2" dependencies: @@ -28945,7 +28943,7 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0, playwright-core@npm:>=1.2.0": +"playwright-core@npm:1.52.0, playwright-core@npm:>=1.2.0, playwright-core@npm:~1.52.0": version: 1.52.0 resolution: "playwright-core@npm:1.52.0" bin: @@ -28954,7 +28952,7 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.55.0, playwright-core@npm:~1.55.0": +"playwright-core@npm:1.55.0": version: 1.55.0 resolution: "playwright-core@npm:1.55.0" bin: @@ -28963,16 +28961,16 @@ __metadata: languageName: node linkType: hard -"playwright-qase-reporter@npm:^2.1.5": - version: 2.1.5 - resolution: "playwright-qase-reporter@npm:2.1.5" +"playwright-qase-reporter@npm:^2.1.3": + version: 2.1.6 + resolution: "playwright-qase-reporter@npm:2.1.6" dependencies: chalk: "npm:^4.1.2" - qase-javascript-commons: "npm:~2.4.1" + qase-javascript-commons: "npm:~2.4.2" uuid: "npm:^9.0.0" peerDependencies: "@playwright/test": ">=1.16.3" - checksum: 10/7f0b5079563fe9207c8a6eb5c48f48cfac9e9d21cb4b058264843c875f93789313e8bdf761a77f9596dc04a5bd915e4cf7c397bd49be1dd5fe0e1237cf7065be + checksum: 10/e6dec661445dc593f9ea32d3ec0e601447f6db36266b45f2fc4f643d9359081cfc496966c3f3a2d139bbc61b8b4703a3dc2829a59524683ba4083086b655cf98 languageName: node linkType: hard @@ -30244,9 +30242,9 @@ __metadata: languageName: node linkType: hard -"qase-javascript-commons@npm:~2.4.1": - version: 2.4.1 - resolution: "qase-javascript-commons@npm:2.4.1" +"qase-javascript-commons@npm:~2.4.2": + version: 2.4.3 + resolution: "qase-javascript-commons@npm:2.4.3" dependencies: ajv: "npm:^8.12.0" async-mutex: "npm:~0.5.0" @@ -30261,7 +30259,7 @@ __metadata: qase-api-v2-client: "npm:~1.0.1" strip-ansi: "npm:^6.0.1" uuid: "npm:^9.0.0" - checksum: 10/6a0b4263414fcbee2a5bed562a9dd14cff2ce780590a92e73aa43424231495f58db2031f54d0fee8d7ffd8f92256702af668af2a50b996603b0e7c21b1f26582 + checksum: 10/fe6c77b2a17aa39b384c767a78463256246d58a370b99a93215a70a9c00f90db673acb38056c00706c6bb0889ffa22fdbc145b23aeb5e20cd9066098fcfafa17 languageName: node linkType: hard @@ -31780,7 +31778,7 @@ __metadata: "@types/ejson": "npm:^2.2.2" "@types/express": "npm:^4.17.23" "@types/fibers": "npm:^3.1.4" - "@types/node": "npm:~22.16.5" + "@types/node": "npm:~22.16.1" "@types/ws": "npm:^8.5.13" ajv: "npm:^8.17.1" bcrypt: "npm:^5.1.1" @@ -34655,7 +34653,7 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:^10.9.2, ts-node@npm:~10.9.2": +"ts-node@npm:^10.9.2": version: 10.9.2 resolution: "ts-node@npm:10.9.2" dependencies: From 71cfec6b266fdac0cd6147dce77d695ba330284f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 11:48:42 -0300 Subject: [PATCH 162/251] revert more changes --- .../providers/CustomSoundProvider/CustomSoundProvider.tsx | 2 +- apps/meteor/client/views/room/hooks/useE2EEState.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index b8ddb56bda7ee..e06dd7a354a19 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -47,7 +47,7 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { audio.volume = volume; audio.loop = loop; audio.id = soundId; - audio.play().catch(); + audio.play(); audioRefs.current = [...audioRefs.current, audio]; diff --git a/apps/meteor/client/views/room/hooks/useE2EEState.ts b/apps/meteor/client/views/room/hooks/useE2EEState.ts index 78f50e42a1a18..2267e4b327fd5 100644 --- a/apps/meteor/client/views/room/hooks/useE2EEState.ts +++ b/apps/meteor/client/views/room/hooks/useE2EEState.ts @@ -4,6 +4,6 @@ import { e2e } from '../../../lib/e2ee'; import type { E2EEState } from '../../../lib/e2ee/E2EEState'; const subscribe = (callback: () => void): (() => void) => e2e.on('E2E_STATE_CHANGED', callback); -const getSnapshot = (): E2EEState | undefined => e2e.getState(); +const getSnapshot = (): E2EEState => e2e.getState(); -export const useE2EEState = (): E2EEState | undefined => useSyncExternalStore(subscribe, getSnapshot); +export const useE2EEState = (): E2EEState => useSyncExternalStore(subscribe, getSnapshot); From 1e884a9e06613e7a1d0e0b5f8a786efd6b3145c3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 12:10:19 -0300 Subject: [PATCH 163/251] revert more changes --- apps/meteor/client/lib/e2ee/logger.ts | 67 +++++++++++++------ .../views/room/E2EESetup/RoomE2EESetup.tsx | 3 +- apps/meteor/client/views/root/AppLayout.tsx | 8 +-- .../views/root/MainLayout/LoggedInArea.tsx | 4 +- 4 files changed, 53 insertions(+), 29 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index a15a9fc5323a4..fbd23a1082ec4 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -1,20 +1,31 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -type LogLevel = 'info' | 'warn' | 'error'; +import { getConfig } from '../utils/getConfig'; -const styles: Record = { - info: 'font-weight: bold;', - warn: 'color: black; background-color: yellow; font-weight: bold;', - error: 'color: white; background-color: red; font-weight: bold;', +let debug: boolean | undefined = undefined; + +const isDebugEnabled = (): boolean => { + if (debug === undefined) { + debug = getConfig('debug') === 'true' || getConfig('debug-e2e') === 'true'; + } + + return debug; }; -interface ConsoleWithContext extends Console { + +interface IConsoleWithContext extends Console { + /** + * Creates a new console context with the given label. + * @remarks + * This is currently only supported in Chromium-based browsers. + * {@link https://blogs.windows.com/msedgedev/2025/04/22/contextual-logging-with-console-context/ | More info} + */ context(label: string): Console; } -const console = ((): ConsoleWithContext => { +const console = ((): IConsoleWithContext => { + if ('context' in globalThis.console) { - return globalThis.console as ConsoleWithContext; + return globalThis.console as IConsoleWithContext; } return { @@ -26,6 +37,15 @@ const console = ((): ConsoleWithContext => { }; })(); +const noopSpan: ISpan = { + set(_key: string, _value: unknown) { + return this; + }, + info(_message: string) { /**/ }, + warn(_message: string) { /**/ }, + error(_message: string, _error?: unknown) { /**/ }, +}; + class Logger { title: string; @@ -33,23 +53,26 @@ class Logger { this.title = title; } - span(label: string) { - return new Span(new WeakRef(this), label, console.context(this.title)); + span(label: string): ISpan { + return isDebugEnabled() ? new Span(new WeakRef(this), label, console.context(this.title)) : noopSpan; } +} - instrument(label: string, fn: () => void) { - const span = this.span(label); - try { - span.info('start'); - fn(); - span.info('end'); - } catch (error) { - span.error('error', error); - throw error; - } - } +interface ISpan { + set(key: string, value: unknown): this; + info(message: string): void; + warn(message: string): void; + error(message: string, error?: unknown): void; } +type LogLevel = 'info' | 'warn' | 'error'; + +const styles: Record = { + info: 'font-weight: bold;', + warn: 'color: black; background-color: yellow; font-weight: bold;', + error: 'color: white; background-color: red; font-weight: bold;', +}; + class Span { private logger: WeakRef; diff --git a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx index bdd604989cb84..d07abb40a366e 100644 --- a/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx +++ b/apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx @@ -1,4 +1,5 @@ import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; +import { Accounts } from 'meteor/accounts-base'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,7 +18,7 @@ const RoomE2EESetup = () => { const e2eRoomState = useE2EERoomState(room._id); const { t } = useTranslation(); - const randomPassword = localStorage.getItem('e2e.randomPassword'); + const randomPassword = Accounts.storageLocation.getItem('e2e.randomPassword'); const onSavePassword = useCallback(() => { if (!randomPassword) { diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index e46fa12b2ca82..cb671e3fea60a 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -22,13 +22,13 @@ import { useCorsSSLConfig } from '../../../app/cors/client/useCorsSSLConfig'; import { useEmojiOne } from '../../../app/emoji-emojione/client/hooks/useEmojiOne'; import { useLivechatEnterprise } from '../../../app/livechat-enterprise/hooks/useLivechatEnterprise'; import { useIframeLoginListener } from '../../hooks/iframe/useIframeLoginListener'; -// import { useNotificationPermission } from '../../hooks/notification/useNotificationPermission'; +import { useNotificationPermission } from '../../hooks/notification/useNotificationPermission'; import { useAnalytics } from '../../hooks/useAnalytics'; import { useAnalyticsEventTracking } from '../../hooks/useAnalyticsEventTracking'; import { useAutoupdate } from '../../hooks/useAutoupdate'; import { useLoadRoomForAllowedAnonymousRead } from '../../hooks/useLoadRoomForAllowedAnonymousRead'; import { appLayout } from '../../lib/appLayout'; -// import { useRedirectToSetupWizard } from '../../startup/useRedirectToSetupWizard'; +import { useRedirectToSetupWizard } from '../../startup/useRedirectToSetupWizard'; const AppLayout = () => { useEffect(() => { @@ -46,9 +46,9 @@ const AppLayout = () => { useEscapeKeyStroke(); useAnalyticsEventTracking(); useLoadRoomForAllowedAnonymousRead(); - // useNotificationPermission(); + useNotificationPermission(); useEmojiOne(); - // useRedirectToSetupWizard(); + useRedirectToSetupWizard(); useSettingsOnLoadSiteUrl(); useLivechatEnterprise(); useNextcloudOAuth(); diff --git a/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx b/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx index 8b1f9a97dd43d..b0028f516b448 100644 --- a/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx +++ b/apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx @@ -3,7 +3,7 @@ import { type ReactNode } from 'react'; import { useCustomEmoji } from '../hooks/loggedIn/useCustomEmoji'; import { useE2EEncryption } from '../hooks/loggedIn/useE2EEncryption'; -// import { useFingerprintChange } from '../hooks/loggedIn/useFingerprintChange'; +import { useFingerprintChange } from '../hooks/loggedIn/useFingerprintChange'; import { useFontStylePreference } from '../hooks/loggedIn/useFontStylePreference'; import { useForceLogout } from '../hooks/loggedIn/useForceLogout'; import { useLogoutCleanup } from '../hooks/loggedIn/useLogoutCleanup'; @@ -38,7 +38,7 @@ const LoggedInArea = ({ children }: { children: ReactNode }) => { useRestrictedRoles(); // These 3 hooks below need to be called in this order due to the way our `setModal` works. // TODO: reevaluate `useSetModal` - // useFingerprintChange(); + useFingerprintChange(); useRootUrlChange(); useTwoFactorAuthSetupCheck(); // From c9721ed5ceb5ba736f54bc8c800ae12a847f2355 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 12:44:49 -0300 Subject: [PATCH 164/251] revert yarn.lock --- yarn.lock | 82 ++++++++++++------------------------------------------- 1 file changed, 18 insertions(+), 64 deletions(-) diff --git a/yarn.lock b/yarn.lock index 11a4c45c962b4..26dee18b2b923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4937,13 +4937,13 @@ __metadata: linkType: hard "@playwright/test@npm:^1.52.0": - version: 1.55.0 - resolution: "@playwright/test@npm:1.55.0" + version: 1.52.0 + resolution: "@playwright/test@npm:1.52.0" dependencies: - playwright: "npm:1.55.0" + playwright: "npm:1.52.0" bin: playwright: cli.js - checksum: 10/bfaede669b0583ee6ec48bfe9ff531cac58da1b83031907d5d0fd71c33bab6766669a23359e401066d97c8e8fa76566900f71be7b290d12213ebb3530a82afbb + checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88 languageName: node linkType: hard @@ -11980,7 +11980,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=0.0.2, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18, @types/node@npm:>=18.0.0, @types/node@npm:>=4.0.0": +"@types/node@npm:*, @types/node@npm:>=0.0.2, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18, @types/node@npm:>=18.0.0, @types/node@npm:>=4.0.0, @types/node@npm:~22.16.1": version: 22.16.1 resolution: "@types/node@npm:22.16.1" dependencies: @@ -12003,15 +12003,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:~22.16.1": - version: 22.16.5 - resolution: "@types/node@npm:22.16.5" - dependencies: - undici-types: "npm:~6.21.0" - checksum: 10/ba45b5c9113cbc5edb12960fcfe7e80db2c998af5c1931264240695b27d756570d92462150b95781bd67a03aa82111cc970ab0f4504eb99213edff8bf425354e - languageName: node - linkType: hard - "@types/nodemailer@npm:*, @types/nodemailer@npm:^6.4.17": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -19296,13 +19287,13 @@ __metadata: linkType: hard "eslint-plugin-playwright@npm:~2.2.0": - version: 2.2.2 - resolution: "eslint-plugin-playwright@npm:2.2.2" + version: 2.2.0 + resolution: "eslint-plugin-playwright@npm:2.2.0" dependencies: globals: "npm:^13.23.0" peerDependencies: eslint: ">=8.40.0" - checksum: 10/861bb486e30e607a3c38bf46b69b01e759a2e7db3d7e79eabb18aa006c505b632b6c6f24e8402b59b8a9b5f94a72ed454fd5a934265081028a1ca0dd4582019f + checksum: 10/f17255ab715a7b57b30080d3b279b4088041bcc4733c4727ba495052ce500a5bd020b4235287da48e4251281d39039e1d55ab37904cbc4522dcf0a1ae6775b39 languageName: node linkType: hard @@ -20643,19 +20634,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.4": - version: 4.0.4 - resolution: "form-data@npm:4.0.4" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb - languageName: node - linkType: hard - "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -28952,44 +28930,20 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.55.0": - version: 1.55.0 - resolution: "playwright-core@npm:1.55.0" - bin: - playwright-core: cli.js - checksum: 10/843376a8e2c1100d3f0625b2c85a69dc4418a0b268bdc7da6cf28252f6dc50c299c734f090a67c7f36cfa9140dacf5b95e817664e69642bcbaf06eea4d216c35 - languageName: node - linkType: hard - "playwright-qase-reporter@npm:^2.1.3": - version: 2.1.6 - resolution: "playwright-qase-reporter@npm:2.1.6" + version: 2.1.3 + resolution: "playwright-qase-reporter@npm:2.1.3" dependencies: chalk: "npm:^4.1.2" - qase-javascript-commons: "npm:~2.4.2" + qase-javascript-commons: "npm:~2.3.3" uuid: "npm:^9.0.0" peerDependencies: "@playwright/test": ">=1.16.3" - checksum: 10/e6dec661445dc593f9ea32d3ec0e601447f6db36266b45f2fc4f643d9359081cfc496966c3f3a2d139bbc61b8b4703a3dc2829a59524683ba4083086b655cf98 - languageName: node - linkType: hard - -"playwright@npm:1.55.0": - version: 1.55.0 - resolution: "playwright@npm:1.55.0" - dependencies: - fsevents: "npm:2.3.2" - playwright-core: "npm:1.55.0" - dependenciesMeta: - fsevents: - optional: true - bin: - playwright: cli.js - checksum: 10/df02529693923b9b671822dac8a2ac8a8471c9142a05e14a24f8d0038a7dcf705712363991a070fc927466bbbe70e485145cf7a77882613822f56960afc4b892 + checksum: 10/4427e4b4c7a0916358ab6116791da5fc05cc065d1eae95c8ca0c49f14b44fc81e0054fee57227e6eec4edfc1b2a38460e73bb402676774621f26a1e6d81ada37 languageName: node linkType: hard -"playwright@npm:^1.14.0": +"playwright@npm:1.52.0, playwright@npm:^1.14.0": version: 1.52.0 resolution: "playwright@npm:1.52.0" dependencies: @@ -30242,15 +30196,15 @@ __metadata: languageName: node linkType: hard -"qase-javascript-commons@npm:~2.4.2": - version: 2.4.3 - resolution: "qase-javascript-commons@npm:2.4.3" +"qase-javascript-commons@npm:~2.3.3": + version: 2.3.5 + resolution: "qase-javascript-commons@npm:2.3.5" dependencies: ajv: "npm:^8.12.0" async-mutex: "npm:~0.5.0" chalk: "npm:^4.1.2" env-schema: "npm:^5.2.0" - form-data: "npm:^4.0.4" + form-data: "npm:^4.0.0" lodash.get: "npm:^4.4.2" lodash.merge: "npm:^4.6.2" lodash.mergewith: "npm:^4.6.2" @@ -30259,7 +30213,7 @@ __metadata: qase-api-v2-client: "npm:~1.0.1" strip-ansi: "npm:^6.0.1" uuid: "npm:^9.0.0" - checksum: 10/fe6c77b2a17aa39b384c767a78463256246d58a370b99a93215a70a9c00f90db673acb38056c00706c6bb0889ffa22fdbc145b23aeb5e20cd9066098fcfafa17 + checksum: 10/8028f9424bf97054f1974c3b663a438f97ebf2f3f487cf9260a3d28047abe2883c870087f1619e67ce5334cc3c88361c08e9566b36052fd316b2bac3096e0712 languageName: node linkType: hard From 10b0214300d899bed132d90aa7e5ecd2a3012250 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 14:09:35 -0300 Subject: [PATCH 165/251] formatting --- .../server/functions/notifications/desktop.ts | 2 +- .../notification/useDesktopNotification.ts | 1 - .../hooks/roomActions/useE2EERoomAction.ts | 7 +---- apps/meteor/client/lib/e2ee/helper.ts | 3 +- apps/meteor/client/lib/e2ee/logger.ts | 29 ++++++++++--------- apps/meteor/client/lib/e2ee/wordList.ts | 2 +- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 4 +-- 7 files changed, 22 insertions(+), 26 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index f98c149998d11..9b49f53582063 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -59,7 +59,7 @@ export async function notifyDesktopUser({ }), ...('content' in message && { content: message.content, - }) + }), }, name, audioNotificationValue, diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index 45ebdf8bc13c2..bbd5cb740e06b 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -24,7 +24,6 @@ export const useDesktopNotification = () => { const { message } = notification.payload; - if (message.t === 'e2e' && message.content) { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index b3d4edecbd4c6..ffb254b7b8518 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -32,12 +32,7 @@ export const useE2EERoomAction = () => { const { otrState } = useOTR(); const isE2EERoomNotReady = () => { - if ( - e2eeRoomState === 'NOT_STARTED' || - e2eeRoomState === 'DISABLED' || - e2eeRoomState === 'ERROR' || - e2eeRoomState === 'WAITING_KEYS' - ) { + if (e2eeRoomState === 'NOT_STARTED' || e2eeRoomState === 'DISABLED' || e2eeRoomState === 'ERROR' || e2eeRoomState === 'WAITING_KEYS') { return true; } diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 374f3eed5a81d..70d0e4e9af29d 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -183,8 +183,7 @@ export function readFileAsArrayBuffer(file: File) { * Generates 12 uniformly random words from the word list. * * @remarks - * Uses rejection sampling to ensure uniform distribution. - * {@link https://en.wikipedia.org/wiki/Rejection_sampling} + * Uses {@link https://en.wikipedia.org/wiki/Rejection_sampling | rejection sampling} to ensure uniform distribution. * * @returns A space-separated passphrase. */ diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index fbd23a1082ec4..27cabeb920470 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -10,10 +10,8 @@ const isDebugEnabled = (): boolean => { return debug; }; - - interface IConsoleWithContext extends Console { - /** + /** * Creates a new console context with the given label. * @remarks * This is currently only supported in Chromium-based browsers. @@ -23,17 +21,16 @@ interface IConsoleWithContext extends Console { } const console = ((): IConsoleWithContext => { - if ('context' in globalThis.console) { return globalThis.console as IConsoleWithContext; } return { ...globalThis.console, - - context(_label: string) { - return globalThis.console - } + + context() { + return globalThis.console; + }, }; })(); @@ -41,9 +38,15 @@ const noopSpan: ISpan = { set(_key: string, _value: unknown) { return this; }, - info(_message: string) { /**/ }, - warn(_message: string) { /**/ }, - error(_message: string, _error?: unknown) { /**/ }, + info(_message: string) { + /**/ + }, + warn(_message: string) { + /**/ + }, + error(_message: string, _error?: unknown) { + /**/ + }, }; class Logger { @@ -90,11 +93,11 @@ class Span { private log(level: LogLevel, message: string) { this.console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'font-weight: normal;'); - this.console.dir(Object.fromEntries(this.attributes.entries()), { }); + this.console.dir(Object.fromEntries(this.attributes.entries()), {}); this.console.groupEnd(); this.console.trace(); } - + set(key: string, value: unknown) { this.attributes.set(key, value); return this; diff --git a/apps/meteor/client/lib/e2ee/wordList.ts b/apps/meteor/client/lib/e2ee/wordList.ts index eca3273da3466..33bd63b0698a3 100644 --- a/apps/meteor/client/lib/e2ee/wordList.ts +++ b/apps/meteor/client/lib/e2ee/wordList.ts @@ -2051,4 +2051,4 @@ export const wordlist = [ 'zero', 'zone', 'zoo', -]; \ No newline at end of file +]; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 0c0da78bc5029..b9f76cfcc385b 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -603,7 +603,7 @@ test.describe('e2e-encryption', () => { await poHomeChannel.sidenav.btnCreate.click(); await expect(page).toHaveURL(`/direct/user2${Users.userE2EE.data.username}`); - await expect(page.getByRole('textbox', { name: 'Message @user2' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Message @user2' })).toBeVisible(); await poHomeChannel.tabs.kebab.click({ force: true }); if (await poHomeChannel.tabs.btnDisableE2E.isVisible()) { @@ -938,7 +938,7 @@ test.describe('e2e-encryption', () => { // send old format encrypted message via API const msg = await page.evaluate(async (rid) => { // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports - const { e2e }: typeof import('../../client/lib/e2ee/rocketchat.e2e.ts') = require('/client/lib/e2ee/rocketchat.e2e.ts'); + const { e2e }: typeof import('../../client/lib/e2ee/rocketchat.e2e.ts') = require('/client/lib/e2ee/rocketchat.e2e.ts'); const e2eRoom = await e2e.getInstanceByRoomId(rid); // @ts-expect-error - _id is required, but we don't need it to encrypt a message return e2eRoom?.encrypt({ _id: 'id', msg: 'Old format message', rid }); From b2139efa92dec0f7174c2935934dab9832e6f011 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 16:28:29 -0300 Subject: [PATCH 166/251] fix pinned message decryption --- .../app/message-pin/server/pinMessage.ts | 1 + apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 31 +++++++++---------- .../core-typings/src/IMessage/IMessage.ts | 1 + 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 012c9bb2a23d8..002c790fb1550 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -119,6 +119,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D text: originalMessage.msg, author_name: originalMessage.u.username, author_icon: getUserAvatarURL(originalMessage.u.username), + content: originalMessage.content, ts: originalMessage.ts, attachments: attachments.map(recursiveRemove), }, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 7230dade5ae74..180b5de288ac8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -685,29 +685,28 @@ class E2E extends Emitter { } async decryptPinnedMessage(message: IE2EEPinnedMessage) { - return message; - // const pinnedMessage = message?.attachments?.[0]?.; + const span = log.span('decryptPinnedMessage').set('message', message); + const [pinnedMessage] = message.attachments; - // if (!pinnedMessage) { - // return message; - // } + if (!pinnedMessage) { + span.warn('No pinned message found'); + return message; + } - // const e2eRoom = await this.getInstanceByRoomId(message.rid); + const e2eRoom = await this.getInstanceByRoomId(message.rid); - // if (!e2eRoom) { - // return message; - // } + if (!e2eRoom) { + span.warn('No e2eRoom found'); + return message; + } - // const data = await e2eRoom.decrypt({ ciphertext: pinnedMessage }); + const data = await e2eRoom.decrypt(pinnedMessage.content); - // if (!data) { - // return message; - // } + message.attachments[0].text = data.msg ?? data.text; - // const decryptedPinnedMessage = { ...message } as IMessage & { attachments: MessageAttachment[] }; - // decryptedPinnedMessage.attachments[0].text = typeof data.msg === 'undefined' ? data.text : data.msg; + span.set('decryptedPinnedMessage', message).info('pinned message decrypted'); - // return decryptedPinnedMessage; + return message; } async decryptPendingMessages(): Promise { diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index e30b716dedbc1..5263ce42a7d61 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -375,6 +375,7 @@ export type IE2EEMessage = IMessage & { export type IE2EEPinnedMessage = IMessage & { t: 'message_pinned_e2e'; + attachments: [MessageAttachment & { content: EncryptedContent }]; }; export interface IOTRMessage extends IMessage { From a6f2857b00c7bc8d56009b5ad39c717a1d07512f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 17:38:13 -0300 Subject: [PATCH 167/251] quick fix for old keys --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 3922c63c90c87..84b08ecbf40e2 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -363,7 +363,11 @@ export class E2ERoom extends Emitter { } async exportSessionKey(key: string) { - key = key.slice(12); + if (key.includes('-')) { + key = key.slice(36) + } else { + key = key.slice(12); + } const decodedKey = Base64.decode(key); if (!e2e.privateKey) { @@ -377,7 +381,10 @@ export class E2ERoom extends Emitter { async importGroupKey(groupKey: string) { const span = log.span('importGroupKey'); // Get existing group key - const keyID = groupKey.slice(0, 36); + + const keyIDLength = groupKey.includes('-') ? 36 : 12; + + const keyID = groupKey.slice(0, keyIDLength); span.set('kid', keyID); @@ -386,7 +393,7 @@ export class E2ERoom extends Emitter { return true; } - groupKey = groupKey.slice(36); + groupKey = groupKey.slice(keyIDLength); const decodedGroupKey = Base64.decode(groupKey); span.set('group_key', groupKey); From c474fc13548dbbf8d1fd11d97963404a2e7fcdb5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 15 Sep 2025 18:24:35 -0300 Subject: [PATCH 168/251] add some more logging --- apps/meteor/client/lib/e2ee/logger.ts | 26 +------------------ .../client/lib/e2ee/rocketchat.e2e.room.ts | 6 ++++- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index 27cabeb920470..d917335039224 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -10,30 +10,6 @@ const isDebugEnabled = (): boolean => { return debug; }; -interface IConsoleWithContext extends Console { - /** - * Creates a new console context with the given label. - * @remarks - * This is currently only supported in Chromium-based browsers. - * {@link https://blogs.windows.com/msedgedev/2025/04/22/contextual-logging-with-console-context/ | More info} - */ - context(label: string): Console; -} - -const console = ((): IConsoleWithContext => { - if ('context' in globalThis.console) { - return globalThis.console as IConsoleWithContext; - } - - return { - ...globalThis.console, - - context() { - return globalThis.console; - }, - }; -})(); - const noopSpan: ISpan = { set(_key: string, _value: unknown) { return this; @@ -57,7 +33,7 @@ class Logger { } span(label: string): ISpan { - return isDebugEnabled() ? new Span(new WeakRef(this), label, console.context(this.title)) : noopSpan; + return isDebugEnabled() ? new Span(new WeakRef(this), label, console) : noopSpan; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 84b08ecbf40e2..50f3f1e3a6239 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -35,7 +35,7 @@ import { Messages, Rooms, Subscriptions } from '../../stores'; // import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; -const log = createLogger('Room'); +const log = createLogger('E2E:Room'); const KEY_ID = Symbol('keyID'); const PAUSED = Symbol('PAUSED'); @@ -325,6 +325,7 @@ export class E2ERoom extends Emitter { if (groupKey) { await this.importGroupKey(groupKey); this.setState('READY'); + span.info('Group key imported'); return; } } catch (error) { @@ -501,6 +502,7 @@ export class E2ERoom extends Emitter { const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); if (!users.length) { + span.info('No users to encrypt the key for'); return; } @@ -515,6 +517,7 @@ export class E2ERoom extends Emitter { for await (const user of users) { const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); if (!encryptedGroupKey) { + span.warn(`Could not encrypt group key for user ${user._id}`); return; } if (decryptedOldGroupKeys) { @@ -536,6 +539,7 @@ export class E2ERoom extends Emitter { async encryptOldKeysForParticipant(publicKey: string, oldRoomKeys: { E2EKey: string; e2eKeyId: string; ts: Date }[]) { const span = log.span('encryptOldKeysForParticipant'); if (!oldRoomKeys || oldRoomKeys.length === 0) { + span.info('Nothing to do'); return; } From 4ae6ca15fcae98798847167b222656dcd4e60532 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 10:36:14 -0300 Subject: [PATCH 169/251] add more history --- apps/meteor/client/lib/e2ee/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md index b2e4e9beea74f..36058ec627f65 100644 --- a/apps/meteor/client/lib/e2ee/README.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -53,7 +53,9 @@ Whenever a group's membership changes (a user is added or removed), a completely - [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) - [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) - [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) +- [fix: stale data after login expired](https://github.com/RocketChat/Rocket.Chat/pull/36338) - [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) - [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) - [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) -- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) \ No newline at end of file +- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) +- [feat: Improve E2E encryption UI texts](https://github.com/RocketChat/Rocket.Chat/pull/36923) From a8cf5b4f5819c20dea509ee4a2f172952ec2419e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 12:19:30 -0300 Subject: [PATCH 170/251] chore: add more history --- apps/meteor/client/lib/e2ee/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md index 36058ec627f65..1022557220a66 100644 --- a/apps/meteor/client/lib/e2ee/README.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -39,6 +39,7 @@ Whenever a group's membership changes (a user is added or removed), a completely - [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) - [[IMPROVE] Quotes on E2EE Messages](https://github.com/RocketChat/Rocket.Chat/pull/26303) - [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) +- [refactor: Replace `Notifications` in favor of `sdk.stream`;](https://github.com/RocketChat/Rocket.Chat/issues/31409) - [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) - [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) - [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) From 3bf31df1ef1582ac30747e979876a746b279afd5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 12:20:11 -0300 Subject: [PATCH 171/251] chore: keep trace inside log group --- apps/meteor/client/lib/e2ee/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/logger.ts b/apps/meteor/client/lib/e2ee/logger.ts index d917335039224..59190b5340be3 100644 --- a/apps/meteor/client/lib/e2ee/logger.ts +++ b/apps/meteor/client/lib/e2ee/logger.ts @@ -70,8 +70,8 @@ class Span { private log(level: LogLevel, message: string) { this.console.groupCollapsed(`%c[${this.logger.deref()?.title}:${this.label}]%c ${message}`, styles[level], 'font-weight: normal;'); this.console.dir(Object.fromEntries(this.attributes.entries()), {}); - this.console.groupEnd(); this.console.trace(); + this.console.groupEnd(); } set(key: string, value: unknown) { From ac86fca58c7d60aea08e2d0c7ff38f42b20e2aa0 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 13:25:32 -0300 Subject: [PATCH 172/251] chore: add more history --- apps/meteor/client/lib/e2ee/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md index 1022557220a66..2d904f97ff882 100644 --- a/apps/meteor/client/lib/e2ee/README.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -48,6 +48,7 @@ Whenever a group's membership changes (a user is added or removed), a completely - [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) - [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) - [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) +- [fix: Server not notifying users of E2EE key suggestions](https://github.com/RocketChat/Rocket.Chat/pull/33435) - [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) - [chore: Upgrade `@tanstack/query` to v5](https://github.com/RocketChat/Rocket.Chat/pull/33898) - [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) From cad05df4434f785b292c8ee2480d7ca0a00ac5ae Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 19:20:18 -0300 Subject: [PATCH 173/251] feat: support any length of prefix --- apps/meteor/client/lib/e2ee/codec.ts | 30 +++++++++++++++++ .../client/lib/e2ee/rocketchat.e2e.room.ts | 32 ++++++++----------- 2 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/codec.ts diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts new file mode 100644 index 0000000000000..cef8812d226ef --- /dev/null +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -0,0 +1,30 @@ +// A 256-byte array always encodes to 344 characters in Base64. +const DECODED_LENGTH = 256; +const ENCODED_LENGTH = 344; + +export const decodePrefixedBase64 = (inputString: string): [prefix: string, data: Uint8Array] => { + // 1. Validate the input string length + if (inputString.length < ENCODED_LENGTH) { + throw new Error(`Input string is too short. It must be at least ${ENCODED_LENGTH} characters long.`); + } + + // 2. Split the string into its two parts + const prefix = inputString.slice(0, -ENCODED_LENGTH); + const base64Data = inputString.slice(-ENCODED_LENGTH); + + // 3. Decode the Base64 string. atob() decodes to a "binary string". + const decodedBinaryString = atob(base64Data); + + // 4. Convert the binary string into a proper byte array (Uint8Array) + const bytes = new Uint8Array(decodedBinaryString.length); + for (let i = 0; i < decodedBinaryString.length; i++) { + bytes[i] = decodedBinaryString.charCodeAt(i); + } + + if (bytes.length !== DECODED_LENGTH) { + // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. + throw new Error(`Decoded data length is ${bytes.length}, but expected ${DECODED_LENGTH} bytes.`); + } + + return [prefix, bytes]; +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 50f3f1e3a6239..5679b7a362f71 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -6,6 +6,7 @@ import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; +import { decodePrefixedBase64 } from './codec'; import { toString, toArrayBuffer, @@ -364,46 +365,39 @@ export class E2ERoom extends Emitter { } async exportSessionKey(key: string) { - if (key.includes('-')) { - key = key.slice(36) - } else { - key = key.slice(12); - } - const decodedKey = Base64.decode(key); + const span = log.span('exportSessionKey').set('key', key); + // The length of a base64 encoded block of 256 bytes is always 344 characters + const [prefix, decodedKey] = decodePrefixedBase64(key); + + span.set('prefix', prefix); if (!e2e.privateKey) { + span.error('Private key not found'); throw new Error('Private key not found'); } const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + span.info('Key decrypted'); return toString(decryptedKey); } async importGroupKey(groupKey: string) { const span = log.span('importGroupKey'); - // Get existing group key - - const keyIDLength = groupKey.includes('-') ? 36 : 12; + const [kid, decodedKey] = decodePrefixedBase64(groupKey); - const keyID = groupKey.slice(0, keyIDLength); + span.set('kid', kid); - span.set('kid', keyID); - - if (this.keyID === keyID && this.groupSessionKey) { + if (this.keyID === kid && this.groupSessionKey) { span.info('Key already imported'); return true; } - groupKey = groupKey.slice(keyIDLength); - const decodedGroupKey = Base64.decode(groupKey); - span.set('group_key', groupKey); - // Decrypt obtained encrypted session key try { if (!e2e.privateKey) { throw new Error('Private key not found'); } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedGroupKey); + const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { span.set('error', error).error('Error decrypting group key'); @@ -413,7 +407,7 @@ export class E2ERoom extends Emitter { // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = this.roomKeyId || keyID || crypto.randomUUID(); + this.keyID = this.roomKeyId || kid || crypto.randomUUID(); } // Import session key for use. From 9974c983f5d1344a479d6bdc655a8fe822c299de Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 16 Sep 2025 20:03:14 -0300 Subject: [PATCH 174/251] add encode operation --- apps/meteor/client/lib/e2ee/codec.ts | 25 +++++++++++++++++++ .../client/lib/e2ee/rocketchat.e2e.room.ts | 15 +++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index cef8812d226ef..db22ba3da63bb 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,5 +1,6 @@ // A 256-byte array always encodes to 344 characters in Base64. const DECODED_LENGTH = 256; +// ((4 * 256 / 3) + 3) & ~3 = 344 const ENCODED_LENGTH = 344; export const decodePrefixedBase64 = (inputString: string): [prefix: string, data: Uint8Array] => { @@ -28,3 +29,27 @@ export const decodePrefixedBase64 = (inputString: string): [prefix: string, data return [prefix, bytes]; }; + +export const encodePrefixedBase64 = (prefix: string, data: Uint8Array): string => { + // 1. Validate the input data length + if (data.length !== DECODED_LENGTH) { + throw new Error(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); + } + + // 2. Convert the byte array (Uint8Array) into a "binary string" + let binaryString = ''; + for (let i = 0; i < data.length; i++) { + binaryString += String.fromCharCode(data[i]); + } + + // 3. Encode the binary string to Base64 + const base64Data = btoa(binaryString); + + if (base64Data.length !== ENCODED_LENGTH) { + // This is a sanity check in case something went wrong during encoding. + throw new Error(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); + } + + // 4. Concatenate the prefix and the Base64 string + return prefix + base64Data; +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 5679b7a362f71..4d130c40b0f9f 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -6,7 +6,7 @@ import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; -import { decodePrefixedBase64 } from './codec'; +import { decodePrefixedBase64, encodePrefixedBase64 } from './codec'; import { toString, toArrayBuffer, @@ -552,7 +552,7 @@ export class E2ERoom extends Emitter { continue; } const encryptedKey = await encryptRSA(userKey, toArrayBuffer(oldRoomKey.E2EKey)); - const encryptedKeyToString = oldRoomKey.e2eKeyId + Base64.encode(new Uint8Array(encryptedKey)); + const encryptedKeyToString = encodePrefixedBase64(oldRoomKey.e2eKeyId, new Uint8Array(encryptedKey)); keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); } @@ -575,10 +575,11 @@ export class E2ERoom extends Emitter { // Encrypt session key for this user with his/her public key try { const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); - const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); + const encryptedUserKeyToString = encodePrefixedBase64(this.keyID, new Uint8Array(encryptedUserKey)); + span.info('Group key encrypted for participant'); return encryptedUserKeyToString; } catch (error) { - return span.set('error', error).error('Error encrypting user key'); + return span.error('Error encrypting user key', error); } } @@ -739,8 +740,8 @@ export class E2ERoom extends Emitter { } // v1: kid + base64(vector + ciphertext) const message = typeof payload === 'string' ? payload : payload.ciphertext; - const kid = message.slice(0, 12); - const [iv, ciphertext] = splitVectorAndEncryptedData(Base64.decode(message.slice(12))); + const [kid, decoded] = decodePrefixedBase64(message); + const [iv, ciphertext] = splitVectorAndEncryptedData(decoded); return { kid, iv, @@ -769,7 +770,7 @@ export class E2ERoom extends Emitter { try { const result = await decryptAes(iv.buffer, key, ciphertext.buffer); const ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); - span.set('decrypted', ret).info('decrypted'); + span.info('decrypted'); return ret; } catch (error) { span.set('error', error).error('Error decrypting message'); From 3d22caebca241bc32e167be7cfa09c0c86b27a33 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 17 Sep 2025 16:08:37 -0300 Subject: [PATCH 175/251] chore: use @rocket.chat/base64 --- apps/meteor/client/lib/e2ee/README.md | 46 +------------------ apps/meteor/client/lib/e2ee/codec.ts | 35 ++++++-------- apps/meteor/client/lib/e2ee/docs/HISTORY.md | 46 +++++++++++++++++++ apps/meteor/client/lib/e2ee/helper.ts | 19 ++++---- .../client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 13 +++--- yarn.lock | 32 ++++++------- 7 files changed, 91 insertions(+), 102 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/docs/HISTORY.md diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md index 2d904f97ff882..12d4444de2e34 100644 --- a/apps/meteor/client/lib/e2ee/README.md +++ b/apps/meteor/client/lib/e2ee/README.md @@ -1,4 +1,4 @@ -# Notes +# End-to-End Encryption ## 1. Summary @@ -17,47 +17,3 @@ When a member needs the AES key (either to start a chat or join a group), an exi ### 1.5 Forward Secrecy (NOT implemented) Whenever a group's membership changes (a user is added or removed), a completely new AES-GCM key is generated and securely distributed to all current members. This protects the integrity of the conversation going forward. -## 2. History -- [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) -- [Fix: Change wording on e2e to make a little more clear](https://github.com/RocketChat/Rocket.Chat/pull/12124) -- [Fix: e2e password visible on always-on alert message](https://github.com/RocketChat/Rocket.Chat/pull/12139) -- [New: Option to change E2E key](https://github.com/RocketChat/Rocket.Chat/pull/12169) -- [Improve: Moved the e2e password request to an alert instead of a popup](https://github.com/RocketChat/Rocket.Chat/pull/12172) -- [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) -- [Improve: Rename E2E methods](https://github.com/RocketChat/Rocket.Chat/pull/12175) -- [Improve: Do not start E2E Encryption when accessing admin as embedded](https://github.com/RocketChat/Rocket.Chat/pull/12192) -- [[FIX] E2E data not cleared on logout](https://github.com/RocketChat/Rocket.Chat/pull/12254) -- [[NEW] Option to reset e2e key](https://github.com/RocketChat/Rocket.Chat/pull/12483) -- [Remove directly dependency between lib and e2e](https://github.com/RocketChat/Rocket.Chat/pull/13115) -- [[FIX] E2E messages not decrypting in message threads](https://github.com/RocketChat/Rocket.Chat/pull/14580) -- [[FIX] Not possible to read encrypted messages after disable E2E on channel level](https://github.com/RocketChat/Rocket.Chat/pull/18101) -- [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) -- [[FIX] E2E issues](https://github.com/RocketChat/Rocket.Chat/pull/20704) -- [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) -- [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) -- [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) -- [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) -- [[IMPROVE] Quotes on E2EE Messages](https://github.com/RocketChat/Rocket.Chat/pull/26303) -- [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) -- [refactor: Replace `Notifications` in favor of `sdk.stream`;](https://github.com/RocketChat/Rocket.Chat/issues/31409) -- [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) -- [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) -- [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) -- [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) -- [regression(E2EEncryption): Service Worker not installing on first load](https://github.com/RocketChat/Rocket.Chat/pull/32674) -- [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) -- [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) -- [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) -- [fix: Server not notifying users of E2EE key suggestions](https://github.com/RocketChat/Rocket.Chat/pull/33435) -- [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) -- [chore: Upgrade `@tanstack/query` to v5](https://github.com/RocketChat/Rocket.Chat/pull/33898) -- [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) -- [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) -- [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) -- [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) -- [fix: stale data after login expired](https://github.com/RocketChat/Rocket.Chat/pull/36338) -- [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) -- [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) -- [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) -- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) -- [feat: Improve E2E encryption UI texts](https://github.com/RocketChat/Rocket.Chat/pull/36923) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index db22ba3da63bb..0306641d9faf5 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,30 +1,26 @@ +import { Base64 } from "@rocket.chat/base64"; + // A 256-byte array always encodes to 344 characters in Base64. const DECODED_LENGTH = 256; // ((4 * 256 / 3) + 3) & ~3 = 344 const ENCODED_LENGTH = 344; -export const decodePrefixedBase64 = (inputString: string): [prefix: string, data: Uint8Array] => { +export const decodePrefixedBase64 = (input: string): [prefix: string, data: Uint8Array] => { // 1. Validate the input string length - if (inputString.length < ENCODED_LENGTH) { - throw new Error(`Input string is too short. It must be at least ${ENCODED_LENGTH} characters long.`); + if (input.length < ENCODED_LENGTH) { + throw new RangeError('Invalid input length.'); } // 2. Split the string into its two parts - const prefix = inputString.slice(0, -ENCODED_LENGTH); - const base64Data = inputString.slice(-ENCODED_LENGTH); + const prefix = input.slice(0, -ENCODED_LENGTH); + const base64Data = input.slice(-ENCODED_LENGTH); // 3. Decode the Base64 string. atob() decodes to a "binary string". - const decodedBinaryString = atob(base64Data); - - // 4. Convert the binary string into a proper byte array (Uint8Array) - const bytes = new Uint8Array(decodedBinaryString.length); - for (let i = 0; i < decodedBinaryString.length; i++) { - bytes[i] = decodedBinaryString.charCodeAt(i); - } + const bytes = Base64.decode(base64Data); if (bytes.length !== DECODED_LENGTH) { // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. - throw new Error(`Decoded data length is ${bytes.length}, but expected ${DECODED_LENGTH} bytes.`); + throw new RangeError('Decoded data length is too short.'); } return [prefix, bytes]; @@ -33,23 +29,18 @@ export const decodePrefixedBase64 = (inputString: string): [prefix: string, data export const encodePrefixedBase64 = (prefix: string, data: Uint8Array): string => { // 1. Validate the input data length if (data.length !== DECODED_LENGTH) { - throw new Error(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); + throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); } // 2. Convert the byte array (Uint8Array) into a "binary string" - let binaryString = ''; - for (let i = 0; i < data.length; i++) { - binaryString += String.fromCharCode(data[i]); - } - - // 3. Encode the binary string to Base64 - const base64Data = btoa(binaryString); + const base64Data = Base64.encode(data); if (base64Data.length !== ENCODED_LENGTH) { // This is a sanity check in case something went wrong during encoding. - throw new Error(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); + throw new RangeError(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); } // 4. Concatenate the prefix and the Base64 string return prefix + base64Data; }; + diff --git a/apps/meteor/client/lib/e2ee/docs/HISTORY.md b/apps/meteor/client/lib/e2ee/docs/HISTORY.md new file mode 100644 index 0000000000000..c6638ad70bdaa --- /dev/null +++ b/apps/meteor/client/lib/e2ee/docs/HISTORY.md @@ -0,0 +1,46 @@ + +## History +- [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) +- [Fix: Change wording on e2e to make a little more clear](https://github.com/RocketChat/Rocket.Chat/pull/12124) +- [Fix: e2e password visible on always-on alert message](https://github.com/RocketChat/Rocket.Chat/pull/12139) +- [New: Option to change E2E key](https://github.com/RocketChat/Rocket.Chat/pull/12169) +- [Improve: Moved the e2e password request to an alert instead of a popup](https://github.com/RocketChat/Rocket.Chat/pull/12172) +- [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) +- [Improve: Rename E2E methods](https://github.com/RocketChat/Rocket.Chat/pull/12175) +- [Improve: Do not start E2E Encryption when accessing admin as embedded](https://github.com/RocketChat/Rocket.Chat/pull/12192) +- [[FIX] E2E data not cleared on logout](https://github.com/RocketChat/Rocket.Chat/pull/12254) +- [[NEW] Option to reset e2e key](https://github.com/RocketChat/Rocket.Chat/pull/12483) +- [Remove directly dependency between lib and e2e](https://github.com/RocketChat/Rocket.Chat/pull/13115) +- [[FIX] E2E messages not decrypting in message threads](https://github.com/RocketChat/Rocket.Chat/pull/14580) +- [[FIX] Not possible to read encrypted messages after disable E2E on channel level](https://github.com/RocketChat/Rocket.Chat/pull/18101) +- [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) +- [[FIX] E2E issues](https://github.com/RocketChat/Rocket.Chat/pull/20704) +- [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) +- [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) +- [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) +- [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) +- [[IMPROVE] Quotes on E2EE Messages](https://github.com/RocketChat/Rocket.Chat/pull/26303) +- [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) +- [refactor: Replace `Notifications` in favor of `sdk.stream`;](https://github.com/RocketChat/Rocket.Chat/issues/31409) +- [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) +- [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) +- [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) +- [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) +- [regression(E2EEncryption): Service Worker not installing on first load](https://github.com/RocketChat/Rocket.Chat/pull/32674) +- [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) +- [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) +- [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) +- [fix: Server not notifying users of E2EE key suggestions](https://github.com/RocketChat/Rocket.Chat/pull/33435) +- [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) +- [chore: Upgrade `@tanstack/query` to v5](https://github.com/RocketChat/Rocket.Chat/pull/33898) +- [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) +- [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) +- [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) +- [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) +- [fix: stale data after login expired](https://github.com/RocketChat/Rocket.Chat/pull/36338) +- [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) +- [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) +- [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) +- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) +- [feat: Improve E2E encryption UI texts](https://github.com/RocketChat/Rocket.Chat/pull/36923) +- [feat: Remove e2ee config from edit room panel](https://github.com/RocketChat/Rocket.Chat/pull/36945) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 70d0e4e9af29d..5781f76cf91ad 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -35,12 +35,11 @@ export function joinVectorAndEncryptedData(vector: Uint8Array, encr export function splitVectorAndEncryptedData( cipherText: Uint8Array, - ivLength: 12 | 16 = 16, -): [Uint8Array, Uint8Array] { - const vector = cipherText.slice(0, ivLength); - const encryptedData = cipherText.slice(ivLength); - - return [vector, encryptedData]; + ivLength: 12 | 16, +): { iv: Uint8Array; ciphertext: Uint8Array } { + const iv = cipherText.subarray(0, ivLength); + const ciphertext = cipherText.subarray(ivLength); + return { iv, ciphertext }; } export async function encryptRSA(key: CryptoKey, data: BufferSource) { @@ -85,7 +84,7 @@ export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } -export async function decryptRSA(key: CryptoKey, data: Uint8Array) { +export async function decryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } @@ -128,10 +127,8 @@ export function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray, baseKey: CryptoKey) { +export function deriveKey(salt: BufferSource, baseKey: CryptoKey) { return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, baseKey, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 4d130c40b0f9f..dd9323b35cb50 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -741,7 +741,7 @@ export class E2ERoom extends Emitter { // v1: kid + base64(vector + ciphertext) const message = typeof payload === 'string' ? payload : payload.ciphertext; const [kid, decoded] = decodePrefixedBase64(message); - const [iv, ciphertext] = splitVectorAndEncryptedData(decoded); + const { iv, ciphertext } = splitVectorAndEncryptedData(decoded, 16); return { kid, iv, diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index bb1b62c5c46e6..d37b6f20598aa 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -613,30 +613,29 @@ class E2E extends Emitter { parsePrivateKey(privateKey: string): { iv: Uint8Array; ciphertext: Uint8Array } { const json: unknown = JSON.parse(privateKey); - if (typeof json !== 'object' || json === null) { - throw new Error('Invalid private key format'); + throw new TypeError('Invalid private key format'); } if ('iv' in json && 'ciphertext' in json && typeof json.iv === 'string' && typeof json.ciphertext === 'string') { + // v2: { iv: base64, ciphertext: base64 } return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext) }; } if ('$binary' in json && typeof json.$binary === 'string') { - // Old format, just base64 string + // v1: { $binary: base64(iv[16] + ciphertext) } const binary = Base64.decode(json.$binary); - const [iv, ciphertext] = splitVectorAndEncryptedData(binary, 16); + const { iv, ciphertext } = splitVectorAndEncryptedData(binary, 16); return { iv, ciphertext }; } - throw new Error('Invalid private key format'); + throw new TypeError('Invalid private key format'); } async decodePrivateKey(privateKey: string): Promise { + const { iv, ciphertext } = this.parsePrivateKey(privateKey); const password = await this.requestPasswordAlert(); - const masterKey = await this.getMasterKey(password); - const { iv, ciphertext } = this.parsePrivateKey(privateKey); try { if (!masterKey) { diff --git a/yarn.lock b/yarn.lock index 210f430751330..26f191e3c15a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7390,7 +7390,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -7452,9 +7452,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/fuselage@npm:^0.66.3": - version: 0.66.3 - resolution: "@rocket.chat/fuselage@npm:0.66.3" +"@rocket.chat/fuselage@npm:^0.66.4": + version: 0.66.4 + resolution: "@rocket.chat/fuselage@npm:0.66.4" dependencies: "@rocket.chat/css-in-js": "npm:^0.31.25" "@rocket.chat/css-supports": "npm:^0.31.25" @@ -7472,7 +7472,7 @@ __metadata: react: "*" react-dom: "*" react-virtuoso: "*" - checksum: 10/874f7d4158a1780fac526b855ccedaca7fec76c0ab08e8c3a902b298bd4b26a30ee918b765493ba2438defd50798bfe030702662644e7985d2a8f44d1c5e63f4 + checksum: 10/b8ffe08d01d7a3e548e1bb91dfc9f4eeffaf1158eaa86cbfeb7a8ed0ca763541ef3db7d0ee53037b0be73b71b232f2f1146aac56a2adecfda0a6dfb03bba6137 languageName: node linkType: hard @@ -7484,7 +7484,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -7920,7 +7920,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/freeswitch": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-forms": "npm:^0.1.0" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" @@ -8838,7 +8838,7 @@ __metadata: dependencies: "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:~" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -8974,7 +8974,7 @@ __metadata: "@babel/core": "npm:~7.26.10" "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9007,7 +9007,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9061,7 +9061,7 @@ __metadata: "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9167,7 +9167,7 @@ __metadata: dependencies: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9203,7 +9203,7 @@ __metadata: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9261,7 +9261,7 @@ __metadata: "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" @@ -9331,7 +9331,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-toastbar": "npm:^0.35.0" @@ -9381,7 +9381,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/css-in-js": "npm:~0.31.25" "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/fuselage": "npm:^0.66.3" + "@rocket.chat/fuselage": "npm:^0.66.4" "@rocket.chat/fuselage-hooks": "npm:^0.37.0" "@rocket.chat/fuselage-polyfills": "npm:~0.31.25" "@rocket.chat/fuselage-tokens": "npm:~0.33.2" From 8ae46f9b6a54f874be82b3a0340c95bfe8bd76d2 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 18 Sep 2025 16:21:19 -0300 Subject: [PATCH 176/251] backwards compat first pass --- apps/meteor/client/lib/e2ee/codec.ts | 3 +- apps/meteor/client/lib/e2ee/helper.ts | 34 ++++++++++++--- .../client/lib/e2ee/rocketchat.e2e.room.ts | 28 +++++++------ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 42 ++++++++++++------- 4 files changed, 73 insertions(+), 34 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index 0306641d9faf5..6b3b6a86baa93 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,4 +1,4 @@ -import { Base64 } from "@rocket.chat/base64"; +import { Base64 } from '@rocket.chat/base64'; // A 256-byte array always encodes to 344 characters in Base64. const DECODED_LENGTH = 256; @@ -43,4 +43,3 @@ export const encodePrefixedBase64 = (prefix: string, data: Uint8Array = ['encrypt', 'decrypt']) { +export function importAesKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { + const isCBC = keyData.alg === 'A128CBC' || keyData.alg === 'A192CBC' || keyData.alg === 'A256CBC'; + if (isCBC) { + console.warn('Importing an AES-CBC key. Consider migrating to AES-GCM for better security.'); + return importAesCbcKey(keyData, keyUsages); + } return crypto.subtle.importKey('jwk', keyData, { name: 'AES-GCM' }, true, keyUsages); } @@ -153,11 +165,21 @@ export function importRawKey(keyData: BufferSource, keyUsages: ReadonlyArray) { const span = log.span('encryptText'); - const vector = crypto.getRandomValues(new Uint8Array(12)); try { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const result = await encryptAesGcm(vector.buffer, this.groupSessionKey, data); + const isAesCbc = this.groupSessionKey.algorithm.name === 'AES-CBC'; + if (isAesCbc) { + span.warn('Using deprecated AES-CBC algorithm for encryption. Please upgrade to AES-GCM.'); + } + // AES-GCM uses a 12 byte vector, while AES-CBC uses a 16 byte vector + // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#parameters + // https://www.rfc-editor.org/rfc/rfc5116.html#section-3.1 + // https://tools.ietf.org/html/rfc3602#section-2 + const vector = crypto.getRandomValues(new Uint8Array(isAesCbc ? 16 : 12)); + const result = await encryptAes(vector, this.groupSessionKey, data); const ciphertext = { kid: this.keyID, iv: Base64.encode(vector), @@ -740,7 +744,7 @@ export class E2ERoom extends Emitter { } // v1: kid + base64(vector + ciphertext) const message = typeof payload === 'string' ? payload : payload.ciphertext; - const [kid, decoded] = decodePrefixedBase64(message); + const [kid, decoded] = [message.slice(0, 12), Base64.decode(message.slice(12))]; const { iv, ciphertext } = splitVectorAndEncryptedData(decoded, 16); return { kid, @@ -768,7 +772,7 @@ export class E2ERoom extends Emitter { span.set('type', key.type); span.set('usages', key.usages.toString()); try { - const result = await decryptAes(iv.buffer, key, ciphertext.buffer); + const result = await decryptAes(iv, key, ciphertext); const ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); span.info('decrypted'); return ret; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index d37b6f20598aa..dbf8b3155d3a5 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -18,7 +18,7 @@ import { toArrayBuffer, // joinVectorAndEncryptedData, splitVectorAndEncryptedData, - encryptAesGcm, + encryptAes, // decryptAesGcm, generateRSAKey, exportJWKKey, @@ -513,19 +513,25 @@ class E2E extends Emitter { } async encodePrivateKey(privateKey: string, password: string) { - const masterKey = await this.getMasterKey(password); + const userId = Meteor.userId(); + if (!userId) { + throw new Error('No userId found'); + } + const salt = `v2:${userId}:${crypto.randomUUID()}`; + const masterKey = await this.getMasterKey(password, { salt }); const vector = crypto.getRandomValues(new Uint8Array(12)); - const result = await encryptAesGcm(vector.buffer, masterKey, toArrayBuffer(privateKey)); + const result = await encryptAes(vector, masterKey, toArrayBuffer(privateKey)); return { iv: Base64.encode(vector), ciphertext: Base64.encode(new Uint8Array(result)), + salt, }; } - async getMasterKey(password: string): Promise { + async getMasterKey(password: string, params: { salt: string }): Promise { const baseKey = await importRawKey(toArrayBuffer(password)); - const derivedKey = await deriveKey(toArrayBuffer(Meteor.userId()), baseKey); + const derivedKey = await deriveKey(params.salt, baseKey); return derivedKey; } @@ -580,13 +586,13 @@ class E2E extends Emitter { async decodePrivateKeyFlow() { const password = await this.requestPasswordModal(); - const masterKey = await this.getMasterKey(password); if (!this.db_private_key) { return; } - const { iv, ciphertext } = this.parsePrivateKey(this.db_private_key); + const { iv, ciphertext, salt } = this.parsePrivateKey(this.db_private_key); + const masterKey = await this.getMasterKey(password, { salt }); try { if (!masterKey) { @@ -611,31 +617,39 @@ class E2E extends Emitter { } } - parsePrivateKey(privateKey: string): { iv: Uint8Array; ciphertext: Uint8Array } { + parsePrivateKey(privateKey: string): { iv: Uint8Array; ciphertext: Uint8Array; salt: string } { const json: unknown = JSON.parse(privateKey); if (typeof json !== 'object' || json === null) { throw new TypeError('Invalid private key format'); } - if ('iv' in json && 'ciphertext' in json && typeof json.iv === 'string' && typeof json.ciphertext === 'string') { - // v2: { iv: base64, ciphertext: base64 } - return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext) }; + const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : Meteor.userId()!; + + if ( + 'iv' in json && + 'ciphertext' in json && + typeof json.iv === 'string' && + typeof json.ciphertext === 'string' + ) { + // v2: { iv: base64, ciphertext: base64, salt: string } + return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext), salt }; } if ('$binary' in json && typeof json.$binary === 'string') { // v1: { $binary: base64(iv[16] + ciphertext) } const binary = Base64.decode(json.$binary); const { iv, ciphertext } = splitVectorAndEncryptedData(binary, 16); - return { iv, ciphertext }; + return { iv, ciphertext, salt }; } throw new TypeError('Invalid private key format'); } async decodePrivateKey(privateKey: string): Promise { - const { iv, ciphertext } = this.parsePrivateKey(privateKey); + // const span = log.span('decodePrivateKey'); + const { iv, ciphertext, salt } = this.parsePrivateKey(privateKey); const password = await this.requestPasswordAlert(); - const masterKey = await this.getMasterKey(password); + const masterKey = await this.getMasterKey(password, { salt }); try { if (!masterKey) { From c231af9ccec946c9e7d0dff8e4238bf2e466808e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 18 Sep 2025 20:30:59 -0300 Subject: [PATCH 177/251] separate pbkdf2 logic --- .../meteor/client/lib/e2ee/docs/REFERENCES.md | 3 + apps/meteor/client/lib/e2ee/helper.ts | 24 ------ apps/meteor/client/lib/e2ee/pbkdf2.ts | 45 ++++++++++ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 83 +++++++------------ 4 files changed, 78 insertions(+), 77 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/docs/REFERENCES.md create mode 100644 apps/meteor/client/lib/e2ee/pbkdf2.ts diff --git a/apps/meteor/client/lib/e2ee/docs/REFERENCES.md b/apps/meteor/client/lib/e2ee/docs/REFERENCES.md new file mode 100644 index 0000000000000..b427897821918 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/docs/REFERENCES.md @@ -0,0 +1,3 @@ +- [RFC7696](https://datatracker.ietf.org/doc/html/rfc7696/) +- [RFC8152](https://datatracker.ietf.org/doc/html/rfc8152/) +- [RFC9709](https://datatracker.ietf.org/doc/rfc9709/) \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 1eccf263aae8d..4493b4f445ec2 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -161,30 +161,6 @@ export function importAesKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['deriveKey']) { - return crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages); -} - -export function deriveKey(salt: string, baseKey: CryptoKey) { - if (salt.startsWith('v2:')) { - return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt: toArrayBuffer(salt), iterations: 100_000, hash: 'SHA-256' }, - baseKey, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); - } - // Legacy derivation for salts not starting with 'v2:' - return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt: toArrayBuffer(salt), iterations: 1000, hash: 'SHA-256' }, - baseKey, - { name: 'AES-CBC', length: 256 }, - true, - ['encrypt', 'decrypt'], - ); -} - export function readFileAsArrayBuffer(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts new file mode 100644 index 0000000000000..7c1c7a8ddc21a --- /dev/null +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -0,0 +1,45 @@ +import ByteBuffer from 'bytebuffer'; + +export type Pbkdf2Options = { + salt: string; + iterations: number; +}; + +export type Pbkdf2 = { + decrypt: (iv: Uint8Array, data: Uint8Array) => Promise; + encrypt: (data: string) => Promise<{ iv: Uint8Array; ciphertext: ArrayBuffer }>; +}; + +export function getMasterKey(password: string, { salt, iterations }: Pbkdf2Options): Pbkdf2 { + const deriveKey = async (mode: 'CBC' | 'GCM') => { + const encodedPassword = ByteBuffer.wrap(password, 'binary').toArrayBuffer(); + const baseKey = await crypto.subtle.importKey('raw', encodedPassword, { name: 'PBKDF2' }, false, ['deriveKey']); + const derivedKey = await crypto.subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-256', salt: ByteBuffer.fromBinary(salt).toArrayBuffer(), iterations } satisfies Pbkdf2Params, + baseKey, + { name: `AES-${mode}`, length: 256 } satisfies AesKeyGenParams, + true, + ['encrypt', 'decrypt'], + ); + return derivedKey; + } + + return { + decrypt: async (iv, data) => { + if (iv.length !== 12 && iv.length !== 16) { + throw new Error('Invalid IV length. Must be 12 (for AES-GCM) or 16 (for AES-CBC) bytes.'); + } + + const key = await deriveKey(iv.length === 16 ? 'CBC' : 'GCM'); + const decrypted = await crypto.subtle.decrypt({ name: key.algorithm.name, iv }, key, data); + return ByteBuffer.wrap(decrypted).toString('binary'); + }, + encrypt: async (data) => { + // Always use AES-GCM for new data + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey('GCM'); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, ByteBuffer.fromBinary(data).toArrayBuffer()); + return { iv, ciphertext }; + }, + }; +} diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index dbf8b3155d3a5..67b2e9ab3656e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -13,22 +13,9 @@ import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; -import { - toString, - toArrayBuffer, - // joinVectorAndEncryptedData, - splitVectorAndEncryptedData, - encryptAes, - // decryptAesGcm, - generateRSAKey, - exportJWKKey, - importRSAKey, - importRawKey, - deriveKey, - generatePassphrase, - decryptAes, -} from './helper'; +import { splitVectorAndEncryptedData, generateRSAKey, exportJWKKey, importRSAKey, generatePassphrase } from './helper'; import { createLogger } from './logger'; +import { getMasterKey, type Pbkdf2Options } from './pbkdf2'; import { E2ERoom } from './rocketchat.e2e.room'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; import { getUserAvatarURL } from '../../../app/utils/client'; @@ -512,29 +499,25 @@ class E2E extends Emitter { return randomPassword; } - async encodePrivateKey(privateKey: string, password: string) { + async encodePrivateKey(privateKey: string, password: string): Promise<{ iv: string; ciphertext: string } & Pbkdf2Options> { const userId = Meteor.userId(); if (!userId) { throw new Error('No userId found'); } + const salt = `v2:${userId}:${crypto.randomUUID()}`; - const masterKey = await this.getMasterKey(password, { salt }); + const iterations = 100_000; + + const result = await getMasterKey(password, { salt, iterations }).encrypt(privateKey); - const vector = crypto.getRandomValues(new Uint8Array(12)); - const result = await encryptAes(vector, masterKey, toArrayBuffer(privateKey)); return { - iv: Base64.encode(vector), - ciphertext: Base64.encode(new Uint8Array(result)), + iv: Base64.encode(result.iv), + ciphertext: Base64.encode(new Uint8Array(result.ciphertext)), salt, + iterations, }; } - async getMasterKey(password: string, params: { salt: string }): Promise { - const baseKey = await importRawKey(toArrayBuffer(password)); - const derivedKey = await deriveKey(params.salt, baseKey); - return derivedKey; - } - openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { imperativeModal.open({ component: EnterE2EPasswordModal, @@ -591,15 +574,11 @@ class E2E extends Emitter { return; } - const { iv, ciphertext, salt } = this.parsePrivateKey(this.db_private_key); - const masterKey = await this.getMasterKey(password, { salt }); + const { iv, ciphertext, ...pbkdf2 } = this.parsePrivateKey(this.db_private_key); + const masterKey = getMasterKey(password, pbkdf2); try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await decryptAes(iv, masterKey, ciphertext); - const privateKey = toString(privKey); + const privateKey = await masterKey.decrypt(iv, ciphertext); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); @@ -617,46 +596,44 @@ class E2E extends Emitter { } } - parsePrivateKey(privateKey: string): { iv: Uint8Array; ciphertext: Uint8Array; salt: string } { + parsePrivateKey(privateKey: string): Pbkdf2Options & { iv: Uint8Array; ciphertext: Uint8Array } { const json: unknown = JSON.parse(privateKey); + if (typeof json !== 'object' || json === null) { throw new TypeError('Invalid private key format'); } - const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : Meteor.userId()!; - - if ( - 'iv' in json && - 'ciphertext' in json && - typeof json.iv === 'string' && - typeof json.ciphertext === 'string' - ) { - // v2: { iv: base64, ciphertext: base64, salt: string } - return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext), salt }; - } - if ('$binary' in json && typeof json.$binary === 'string') { // v1: { $binary: base64(iv[16] + ciphertext) } const binary = Base64.decode(json.$binary); const { iv, ciphertext } = splitVectorAndEncryptedData(binary, 16); - return { iv, ciphertext, salt }; + return { iv, ciphertext, iterations: 1000, salt: Meteor.userId()! }; + } + + if (!('iv' in json) || typeof json.iv !== 'string' || !('ciphertext' in json) || typeof json.ciphertext !== 'string') { + throw new TypeError('Invalid private key format'); } - throw new TypeError('Invalid private key format'); + // v2: { iv: base64, ciphertext: base64, salt: string, iterations: number } + + const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : Meteor.userId()!; + const iterations = 'iterations' in json && typeof json.iterations === 'number' ? json.iterations : 100_000; + + return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext), salt, iterations }; } async decodePrivateKey(privateKey: string): Promise { // const span = log.span('decodePrivateKey'); - const { iv, ciphertext, salt } = this.parsePrivateKey(privateKey); const password = await this.requestPasswordAlert(); - const masterKey = await this.getMasterKey(password, { salt }); + const { iv, ciphertext, salt, iterations } = this.parsePrivateKey(privateKey); + const masterKey = await getMasterKey(password, { salt, iterations }); try { if (!masterKey) { throw new Error('Error getting master key'); } - const privKey = await decryptAes(iv, masterKey, ciphertext); - return toString(privKey); + const privKey = await masterKey.decrypt(iv, ciphertext); + return privKey; } catch (error) { this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') }); From 25df556b49ca42c60a8a80cc712bfcfb53b968e7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 18 Sep 2025 20:54:48 -0300 Subject: [PATCH 178/251] binary codec --- apps/meteor/client/lib/e2ee/binary.ts | 26 ++++++++++++++++++++++++++ apps/meteor/client/lib/e2ee/pbkdf2.ts | 18 +++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/binary.ts diff --git a/apps/meteor/client/lib/e2ee/binary.ts b/apps/meteor/client/lib/e2ee/binary.ts new file mode 100644 index 0000000000000..3e8b890d4adcb --- /dev/null +++ b/apps/meteor/client/lib/e2ee/binary.ts @@ -0,0 +1,26 @@ +export const Binary = { + toString(buffer: ArrayBuffer): string { + const uint8 = new Uint8Array(buffer); + const CHUNK_SIZE = 8192; // Process in chunks for performance + let result = ''; + for (let i = 0; i < uint8.length; i += CHUNK_SIZE) { + const chunk = uint8.subarray(i, i + CHUNK_SIZE); + result += String.fromCharCode(...chunk); + } + return result; + }, + toArrayBuffer(str: string): ArrayBuffer { + // Create a Uint8Array of the same length as the string. + // This will be a view on the new ArrayBuffer. + const buffer = new ArrayBuffer(str.length); + const uint8 = new Uint8Array(buffer); + + // Iterate through the string, getting the character code for each + // character and setting it as the value for the corresponding byte. + for (let i = 0; i < str.length; i++) { + uint8[i] = str.charCodeAt(i); + } + + return buffer; + } +} \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index 7c1c7a8ddc21a..fedc9ef014495 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -1,4 +1,4 @@ -import ByteBuffer from 'bytebuffer'; +import { Binary } from './binary'; export type Pbkdf2Options = { salt: string; @@ -11,12 +11,13 @@ export type Pbkdf2 = { }; export function getMasterKey(password: string, { salt, iterations }: Pbkdf2Options): Pbkdf2 { + const encodedPassword = Binary.toArrayBuffer(password); + const encodedSalt = Binary.toArrayBuffer(salt); + const deriveKey = async (mode: 'CBC' | 'GCM') => { - const encodedPassword = ByteBuffer.wrap(password, 'binary').toArrayBuffer(); - const baseKey = await crypto.subtle.importKey('raw', encodedPassword, { name: 'PBKDF2' }, false, ['deriveKey']); const derivedKey = await crypto.subtle.deriveKey( - { name: 'PBKDF2', hash: 'SHA-256', salt: ByteBuffer.fromBinary(salt).toArrayBuffer(), iterations } satisfies Pbkdf2Params, - baseKey, + { name: 'PBKDF2', hash: 'SHA-256', salt: encodedSalt, iterations } satisfies Pbkdf2Params, + await crypto.subtle.importKey('raw', encodedPassword, { name: 'PBKDF2' }, false, ['deriveKey']), { name: `AES-${mode}`, length: 256 } satisfies AesKeyGenParams, true, ['encrypt', 'decrypt'], @@ -29,16 +30,15 @@ export function getMasterKey(password: string, { salt, iterations }: Pbkdf2Optio if (iv.length !== 12 && iv.length !== 16) { throw new Error('Invalid IV length. Must be 12 (for AES-GCM) or 16 (for AES-CBC) bytes.'); } - const key = await deriveKey(iv.length === 16 ? 'CBC' : 'GCM'); const decrypted = await crypto.subtle.decrypt({ name: key.algorithm.name, iv }, key, data); - return ByteBuffer.wrap(decrypted).toString('binary'); + return Binary.toString(decrypted); }, encrypt: async (data) => { // Always use AES-GCM for new data - const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey('GCM'); - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, ByteBuffer.fromBinary(data).toArrayBuffer()); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, Binary.toArrayBuffer(data)); return { iv, ciphertext }; }, }; From 03f7b8951378949880c57f479d4be31ad1278f84 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 19 Sep 2025 15:15:58 -0300 Subject: [PATCH 179/251] chore: undo test changes --- .../tests/e2e/page-objects/fragments/home-content.ts | 11 +++++------ apps/meteor/tests/e2e/page-objects/login.ts | 1 - apps/meteor/tests/e2e/utils/getFilePath.ts | 7 ------- 3 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 apps/meteor/tests/e2e/utils/getFilePath.ts diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 410a4d76a3014..f767c9321245a 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -1,8 +1,7 @@ -import { readFile } from 'node:fs/promises'; +import fs from 'fs/promises'; import type { Locator, Page } from '@playwright/test'; -import { getFilePath } from '../../utils/getFilePath'; import { expect } from '../../utils/test'; export class HomeContent { @@ -380,7 +379,7 @@ export class HomeContent { } async dragAndDropTxtFile(): Promise { - const contract = await readFile(getFilePath('any_file.txt'), 'utf-8'); + const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -396,7 +395,7 @@ export class HomeContent { } async dragAndDropLstFile(): Promise { - const contract = await readFile(getFilePath('lst-test.lst'), 'utf-8'); + const contract = await fs.readFile('./tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'lst-test.lst', { @@ -412,7 +411,7 @@ export class HomeContent { } async dragAndDropTxtFileToThread(): Promise { - const contract = await readFile(getFilePath('any_file.txt'), 'utf-8'); + const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -428,7 +427,7 @@ export class HomeContent { } async sendFileMessage(fileName: string): Promise { - await this.page.locator('input[type=file]').setInputFiles(getFilePath(fileName)); + await this.page.locator('input[type=file]').setInputFiles(`./tests/e2e/fixtures/files/${fileName}`); } async openLastMessageMenu(): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/login.ts b/apps/meteor/tests/e2e/page-objects/login.ts index b6470600258c4..2b6449fd1f4e7 100644 --- a/apps/meteor/tests/e2e/page-objects/login.ts +++ b/apps/meteor/tests/e2e/page-objects/login.ts @@ -41,7 +41,6 @@ export class LoginPage { // Injects the login token to the local storage await this.page.evaluate((items) => { - window.localStorage.clear(); items.forEach(({ name, value }) => { window.localStorage.setItem(name, value); }); diff --git a/apps/meteor/tests/e2e/utils/getFilePath.ts b/apps/meteor/tests/e2e/utils/getFilePath.ts deleted file mode 100644 index 2d109c1ff5665..0000000000000 --- a/apps/meteor/tests/e2e/utils/getFilePath.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { resolve, join, relative } from 'node:path'; - -const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../fixtures/files')); - -export function getFilePath(fileName: string): string { - return join(FIXTURES_PATH, fileName); -} From 3637374c7850c92d791aa6754c761d60e3527e8a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 22 Sep 2025 10:25:03 -0300 Subject: [PATCH 180/251] fix: lint errors --- apps/meteor/client/lib/e2ee/binary.ts | 4 ++-- apps/meteor/client/lib/e2ee/docs/REFERENCES.md | 3 ++- apps/meteor/client/lib/e2ee/pbkdf2.ts | 2 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/binary.ts b/apps/meteor/client/lib/e2ee/binary.ts index 3e8b890d4adcb..4e1df817d06a4 100644 --- a/apps/meteor/client/lib/e2ee/binary.ts +++ b/apps/meteor/client/lib/e2ee/binary.ts @@ -22,5 +22,5 @@ export const Binary = { } return buffer; - } -} \ No newline at end of file + }, +}; diff --git a/apps/meteor/client/lib/e2ee/docs/REFERENCES.md b/apps/meteor/client/lib/e2ee/docs/REFERENCES.md index b427897821918..53e7c51047ead 100644 --- a/apps/meteor/client/lib/e2ee/docs/REFERENCES.md +++ b/apps/meteor/client/lib/e2ee/docs/REFERENCES.md @@ -1,3 +1,4 @@ - [RFC7696](https://datatracker.ietf.org/doc/html/rfc7696/) - [RFC8152](https://datatracker.ietf.org/doc/html/rfc8152/) -- [RFC9709](https://datatracker.ietf.org/doc/rfc9709/) \ No newline at end of file +- [RFC8452](https://datatracker.ietf.org/doc/html/rfc8452/) +- [RFC9709](https://datatracker.ietf.org/doc/html/rfc9709/) \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index fedc9ef014495..c870a3cbdb929 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -23,7 +23,7 @@ export function getMasterKey(password: string, { salt, iterations }: Pbkdf2Optio ['encrypt', 'decrypt'], ); return derivedKey; - } + }; return { decrypt: async (iv, data) => { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 67b2e9ab3656e..a3ca51f51176d 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -626,7 +626,7 @@ class E2E extends Emitter { // const span = log.span('decodePrivateKey'); const password = await this.requestPasswordAlert(); const { iv, ciphertext, salt, iterations } = this.parsePrivateKey(privateKey); - const masterKey = await getMasterKey(password, { salt, iterations }); + const masterKey = getMasterKey(password, { salt, iterations }); try { if (!masterKey) { From b53e205b574ae8ddb42067953ed89ae8f9ac55f9 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 22 Sep 2025 17:31:31 -0300 Subject: [PATCH 181/251] chore: improve logging --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 1caccd1bd63e7..14d8f8c0eb1b8 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -461,11 +461,11 @@ export class E2ERoom extends Emitter { const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; this.setState('READY'); - span.set('kid', this.keyID).set('key', e2eNewKeys.e2eKey).info('Room key reset successfully'); + span.set('kid', this.keyID).info('Room key reset successfully'); return e2eNewKeys; } catch (error) { - span.set('error', error).error('Error resetting room key'); + span.error('Error resetting room key', error); throw error; } } From ce52cde81c0b4005bf93258feeca79b95444d8c6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 23 Sep 2025 11:52:46 -0300 Subject: [PATCH 182/251] restore test file --- .../page-objects/fragments/home-sidenav.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts index 0f66015337e25..86153c41ebb47 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts @@ -23,7 +23,7 @@ export class HomeSidenav { } get checkboxEncryption(): Locator { - return this.page.getByRole('dialog', { name: 'Create channel' }).getByRole('checkbox', { name: 'Encrypted' }); + return this.page.locator('role=dialog[name="Create channel"] >> label >> text="Encrypted"'); } get checkboxReadOnly(): Locator { @@ -235,28 +235,11 @@ export class HomeSidenav { async createEncryptedChannel(name: string) { const toastMessages = new ToastMessages(this.page); - // Open modal and ensure it's visible before interacting await this.openNewByLabel('Channel'); - const dialog = this.page.getByRole('dialog', { name: 'Create channel' }); - await expect(dialog).toBeVisible(); - await this.inputChannelName.fill(name); - - // Expand and wait for checkbox to appear before clicking await this.advancedSettingsAccordion.click(); - await expect(this.checkboxEncryption).toBeVisible(); - - const encryptedField = this.page.locator('fieldset > div', { hasText: 'Encrypted' }); - const encryptedFieldText = await encryptedField.textContent(); - if (!encryptedFieldText?.includes('End-to-end encrypted channel')) { - await this.page - .locator('span') - .filter({ hasText: /^Encrypted$/ }) - .locator('i') - .click(); - } - - await this.page.getByRole('button', { name: 'Create', exact: true }).click(); + await this.checkboxEncryption.click(); + await this.btnCreate.click(); await toastMessages.dismissToast('success'); } From 27083c0515856f0a32291ea46b29d8edf809f66b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 23 Sep 2025 12:06:43 -0300 Subject: [PATCH 183/251] chore: remove Meteor.userId from e2ee --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 51 +++++++++++-------- .../root/hooks/loggedIn/useE2EEncryption.ts | 8 +-- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index a3ca51f51176d..bb5186302c32c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -10,7 +10,6 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import EJSON from 'ejson'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; import type { E2EEState } from './E2EEState'; import { splitVectorAndEncryptedData, generateRSAKey, exportJWKKey, importRSAKey, generatePassphrase } from './helper'; @@ -45,9 +44,9 @@ type KeyPair = { const ROOM_KEY_EXCHANGE_SIZE = 10; class E2E extends Emitter { - private started: boolean; + private userId: string | false = false; - private instancesByRoomId: Record; + private instancesByRoomId: Record = {}; private db_public_key: string | null | undefined; @@ -57,15 +56,12 @@ class E2E extends Emitter { public publicKey: string | undefined; - private keyDistributionInterval: ReturnType | null; + private keyDistributionInterval: ReturnType | null = null; private state: E2EEState; constructor() { super(); - this.started = false; - this.instancesByRoomId = {}; - this.keyDistributionInterval = null; this.on('READY', async () => { await this.onE2EEReady(); @@ -243,7 +239,7 @@ class E2E extends Emitter { async getInstanceByRoomId(rid: IRoom['_id']): Promise { const room = await this.waitForRoom(rid); - const userId = Meteor.userId(); + const userId = this.getUserId(); if (!this.instancesByRoomId[rid] && userId) { this.instancesByRoomId[rid] = new E2ERoom(userId, room); } @@ -331,15 +327,15 @@ class E2E extends Emitter { }); } - async startClient(): Promise { + async startClient(userId: string): Promise { const span = log.span('startClient'); - if (this.started) { + if (this.userId === userId) { return; } span.info(this.state); - this.started = true; + this.userId = userId; let { public_key, private_key } = this.getKeysFromLocalStorage(); @@ -354,7 +350,7 @@ class E2E extends Emitter { this.setState('ENTER_PASSWORD'); private_key = await this.decodePrivateKey(this.db_private_key!); } catch (error) { - this.started = false; + this.userId = false; failedToDecodeKey = true; this.openAlert({ title: "Wasn't possible to decode your encryption key to be imported.", // TODO: missing translation @@ -363,7 +359,7 @@ class E2E extends Emitter { closable: true, icon: 'key', action: async () => { - await this.startClient(); + await this.startClient(userId); this.closeAlert(); }, }); @@ -408,7 +404,7 @@ class E2E extends Emitter { this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; - this.started = false; + this.userId = false; this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); this.keyDistributionInterval = null; this.setState('DISABLED'); @@ -430,6 +426,8 @@ class E2E extends Emitter { this.db_public_key = public_key; this.db_private_key = private_key; + + span.info('fetched keys from db'); } catch (error) { this.setState('ERROR'); span.error('Error fetching RSA keys: ', error); @@ -500,7 +498,7 @@ class E2E extends Emitter { } async encodePrivateKey(privateKey: string, password: string): Promise<{ iv: string; ciphertext: string } & Pbkdf2Options> { - const userId = Meteor.userId(); + const { userId } = this; if (!userId) { throw new Error('No userId found'); } @@ -574,8 +572,8 @@ class E2E extends Emitter { return; } - const { iv, ciphertext, ...pbkdf2 } = this.parsePrivateKey(this.db_private_key); - const masterKey = getMasterKey(password, pbkdf2); + const { iv, ciphertext, salt, iterations } = this.parsePrivateKey(this.db_private_key); + const masterKey = getMasterKey(password, { salt, iterations }); try { const privateKey = await masterKey.decrypt(iv, ciphertext); @@ -598,6 +596,7 @@ class E2E extends Emitter { parsePrivateKey(privateKey: string): Pbkdf2Options & { iv: Uint8Array; ciphertext: Uint8Array } { const json: unknown = JSON.parse(privateKey); + const userId = this.getUserId(); if (typeof json !== 'object' || json === null) { throw new TypeError('Invalid private key format'); @@ -607,7 +606,7 @@ class E2E extends Emitter { // v1: { $binary: base64(iv[16] + ciphertext) } const binary = Base64.decode(json.$binary); const { iv, ciphertext } = splitVectorAndEncryptedData(binary, 16); - return { iv, ciphertext, iterations: 1000, salt: Meteor.userId()! }; + return { iv, ciphertext, iterations: 1000, salt: userId }; } if (!('iv' in json) || typeof json.iv !== 'string' || !('ciphertext' in json) || typeof json.ciphertext !== 'string') { @@ -616,7 +615,7 @@ class E2E extends Emitter { // v2: { iv: base64, ciphertext: base64, salt: string, iterations: number } - const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : Meteor.userId()!; + const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : userId; const iterations = 'iterations' in json && typeof json.iterations === 'number' ? json.iterations : 100_000; return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext), salt, iterations }; @@ -831,13 +830,25 @@ class E2E extends Emitter { return sampleIds; } + getUserId(): string { + const span = log.span('getUserId'); + if (!this.userId) { + span.error('No userId found'); + + throw new Error('No userId found'); + } + + span.set('userId', this.userId).info('found userId'); + return this.userId; + } + async initiateKeyDistribution() { if (this.keyDistributionInterval) { return; } const predicate = (record: IRoom) => - Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== Meteor.userId())); + Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== this.getUserId())); const span = log.span('initiateKeyDistribution'); const keyDistribution = async () => { diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 043015ddd99c9..74ac41ff85e0e 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -21,12 +21,8 @@ export const useE2EEncryption = () => { return; } - if (!window.crypto) { - return; - } - - if (enabled && !adminEmbedded) { - e2e.startClient(); + if (window.crypto && enabled && !adminEmbedded) { + e2e.startClient(userId); } else { e2e.setState('DISABLED'); e2e.closeAlert(); From 38e1d839e7ebcc2f5cd1a12bd57d6ae08331e40b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 23 Sep 2025 12:14:13 -0300 Subject: [PATCH 184/251] remove unused cache --- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 14d8f8c0eb1b8..d3690265f2030 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -95,8 +95,6 @@ export class E2ERoom extends Emitter { sessionKeyExported: JsonWebKey | undefined; - cache: WeakMap = new WeakMap(); - constructor(userId: string, room: IRoom) { super(); From 01b11ab2aa603e0f7376dea833a23f93bffb7433 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 25 Sep 2025 18:02:26 -0300 Subject: [PATCH 185/251] chore: remove unneedded EJSON.parse --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index bb5186302c32c..a798370458317 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -7,7 +7,6 @@ import { isE2EEMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import EJSON from 'ejson'; import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; @@ -443,7 +442,7 @@ class E2E extends Emitter { this.publicKey = public_key; try { - this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); + this.privateKey = await importRSAKey(JSON.parse(private_key), ['decrypt']); Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { From 9a324d6222f8b291135072f30c0534d618eaca0b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 26 Sep 2025 12:50:49 -0300 Subject: [PATCH 186/251] fix: native federation merge tweaks --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 10 +---- .../core-typings/src/IEncryptedContent.ts | 25 ----------- .../core-typings/src/IMessage/IMessage.ts | 44 ++++++++++++++----- packages/core-typings/src/IUpload.ts | 2 +- 4 files changed, 34 insertions(+), 47 deletions(-) delete mode 100644 packages/core-typings/src/IEncryptedContent.ts diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 20fcb60f2018b..933b38a92e0ab 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -613,14 +613,6 @@ export class E2ERoom extends Emitter { }; } - // Decrypt uploaded encrypted files. I/O is in arraybuffers. - async decryptFile(file: Uint8Array, key: JsonWebKey, iv: string) { - const ivArray = Base64.decode(iv); - const cryptoKey = await window.crypto.subtle.importKey('jwk', key, { name: 'AES-CTR' }, true, ['encrypt', 'decrypt']); - - return window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ivArray, length: 64 }, cryptoKey, file); - } - // Encrypts messages async encryptText(data: Uint8Array) { const span = log.span('encryptText'); @@ -705,7 +697,7 @@ export class E2ERoom extends Emitter { } async decryptContent(data: T) { - const content = await this.decrypt(data.content.ciphertext); + const content = await this.decrypt(data.content); Object.assign(data, content); return data; diff --git a/packages/core-typings/src/IEncryptedContent.ts b/packages/core-typings/src/IEncryptedContent.ts deleted file mode 100644 index 9cdde121fe9d8..0000000000000 --- a/packages/core-typings/src/IEncryptedContent.ts +++ /dev/null @@ -1,25 +0,0 @@ -interface IEncryptedContent { - /** - * The encryption algorithm used. - * Currently supported algorithms are: - * - `rc.v1.aes-sha2`: Rocket.Chat E2E Encryption version 1, using AES encryption with SHA-256 hashing. - * - `rc.v2.aes-sha2`: Rocket.Chat E2E Encryption version 2, using AES encryption with SHA-256 hashing and improved key management. - */ - algorithm: string; - ciphertext: string; // base64-encoded encrypted subset JSON of IMessage -} - -interface IEncryptedContentV1 extends IEncryptedContent { - /** - * The encryption algorithm used. - */ - algorithm: 'rc.v1.aes-sha2'; -} - -interface IEncryptedContentV2 extends IEncryptedContent { - algorithm: 'rc.v2.aes-sha2'; - iv: string; // base64-encoded initialization vector - kid: string; // ID of the key used to encrypt the message -} - -export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2; diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 8c4ad115eb966..1e452baf9a1d4 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -4,7 +4,6 @@ import type Icons from '@rocket.chat/icons'; import type { Root } from '@rocket.chat/message-parser'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; -import type { EncryptedContent } from '../IEncryptedContent'; import type { ILivechatPriority } from '../ILivechatPriority'; import type { ILivechatVisitor } from '../ILivechatVisitor'; import type { IOmnichannelServiceLevelAgreements } from '../IOmnichannelServiceLevelAgreements'; @@ -136,6 +135,18 @@ export type MessageMention = { export interface IMessageCustomFields {} +export type EncryptedContent = + | { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; + } + | { + algorithm: 'rc.v2.aes-sha2'; + ciphertext: string; + iv: string; // Initialization Vector + kid: string; // ID of the key used to encrypt the message + }; + export interface IMessage extends IRocketChatRecord { rid: RoomID; msg: string; @@ -237,19 +248,28 @@ export interface IMessage extends IRocketChatRecord { } export type EncryptedMessageContent = { - content: { - algorithm: 'rc.v1.aes-sha2'; - ciphertext: string; - }; + content: EncryptedContent; }; -export const isEncryptedMessageContent = (content: unknown): content is EncryptedMessageContent => - typeof content === 'object' && - content !== null && - 'content' in content && - typeof (content as any).content === 'object' && - (content as any).content?.algorithm === 'rc.v1.aes-sha2'; - +export function isEncryptedMessageContent(value: unknown) { + return ( + typeof value === 'object' && + value !== null && + 'content' in value && + typeof value.content === 'object' && + value.content !== null && + 'algorithm' in value.content && + (value.content.algorithm === 'rc.v1.aes-sha2' || value.content.algorithm === 'rc.v2.aes-sha2') && + 'ciphertext' in value.content && + typeof value.content.ciphertext === 'string' && + (value.content.algorithm === 'rc.v1.aes-sha2' || + (value.content.algorithm === 'rc.v2.aes-sha2' && + 'iv' in value.content && + typeof value.content.iv === 'string' && + 'kid' in value.content && + typeof value.content.kid === 'string')) + ); +} export interface ISystemMessage extends IMessage { t: MessageTypesValues; } diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index 4e3be3521c24d..e27817d620996 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -1,4 +1,4 @@ -import type { EncryptedContent } from './IEncryptedContent'; +import type { EncryptedContent } from './IMessage'; import type { IUser } from './IUser'; export interface IUpload { From 62981575c1f353d2d8c0d2506ec7ad718df7c666 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 26 Sep 2025 14:39:27 -0300 Subject: [PATCH 187/251] fix: typings --- packages/core-typings/src/IMessage/IMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 1e452baf9a1d4..e8a05dd0b5fb5 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -251,7 +251,7 @@ export type EncryptedMessageContent = { content: EncryptedContent; }; -export function isEncryptedMessageContent(value: unknown) { +export function isEncryptedMessageContent(value: unknown): value is EncryptedMessageContent { return ( typeof value === 'object' && value !== null && From fa4ae9c3548a087267ca02bd354f6fd3f4ae30a1 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 29 Sep 2025 19:40:17 -0300 Subject: [PATCH 188/251] fix: support text in content --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 933b38a92e0ab..606cdfdbbe48c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -704,11 +704,19 @@ export class E2ERoom extends Emitter { } // Decrypt messages - async decryptMessage(message: IMessage): Promise { - if (message.e2e === 'done') { + async decryptMessage(message: IMessage | IE2EEMessage): Promise { + if (message.t !== 'e2e' || message.e2e === 'done') { return message; } + if (message.msg) { + const data = await this.decrypt(message.msg); + + if (data?.text) { + message.msg = data.text; + } + } + message = isEncryptedMessageContent(message) ? await this.decryptContent(message) : message; return { @@ -742,8 +750,11 @@ export class E2ERoom extends Emitter { } async decrypt( - message: Required['content'], - ): Promise<{ _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } | { msg: string }> { + message: string | Required['content'], + ): Promise< + | { _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } + | { _id?: undefined; text?: undefined; userId?: undefined; ts?: undefined; msg: string } + > { const span = log.span('decrypt').set('rid', this.roomId); const payload = this.parse(message); span.set('payload', payload); From 4a6abd481815169239976f0e04e7ab80a9969c96 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 29 Sep 2025 19:52:39 -0300 Subject: [PATCH 189/251] fix: legacy test --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 606cdfdbbe48c..16c7401c6a581 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -27,6 +27,7 @@ import { sha256HashFromArrayBuffer, createSha256HashFromText, decryptAes, + joinVectorAndEncryptedData, } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -673,7 +674,7 @@ export class E2ERoom extends Emitter { } // Helper function for encryption of messages - encrypt(message: IMessage) { + async encrypt(message: IMessage) { if (!this.isSupportedRoomType(this.typeOfRoom)) { return; } @@ -693,7 +694,13 @@ export class E2ERoom extends Emitter { }), ); - return this.encryptText(data); + const payload = await this.encryptText(data); + + const iv = Base64.decode(payload.iv); + const ciphertext = Base64.decode(payload.ciphertext); + + const joined = joinVectorAndEncryptedData(iv, ciphertext.buffer); + return `${payload.kid}${Base64.encode(joined)}`; } async decryptContent(data: T) { @@ -740,8 +747,10 @@ export class E2ERoom extends Emitter { } // v1: kid + base64(vector + ciphertext) const message = typeof payload === 'string' ? payload : payload.ciphertext; - const [kid, decoded] = [message.slice(0, 12), Base64.decode(message.slice(12))]; - const { iv, ciphertext } = splitVectorAndEncryptedData(decoded, 16); + const kidLength = message.includes('-') ? 36 : 12; + const ivLength = kidLength === 36 ? 12 : 16; + const [kid, decoded] = [message.slice(0, kidLength), Base64.decode(message.slice(kidLength))]; + const { iv, ciphertext } = splitVectorAndEncryptedData(decoded, ivLength); return { kid, iv, From 74b9606d22f1f4c5270d85d4fc4e2dbdd124e46e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 30 Sep 2025 14:07:18 -0300 Subject: [PATCH 190/251] chore: use pre-baked data for legacy e2ee test --- .../e2e-encryption/e2ee-legacy-format.spec.ts | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts index f82f7ba8a836a..ca4d4103b0ab3 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import type { APIRequestContext, Page } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { BASE_API_URL } from '../config/constants'; import injectInitialData from '../fixtures/inject-initial-data'; @@ -17,18 +17,6 @@ const settingsList = [ preserveSettings(settingsList); -const encryptLegacyMessage = async (page: Page, rid: string, messageText: string) => { - return page.evaluate( - async ({ rid, msg }: { rid: string; msg: string }) => { - // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path - const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts'); - const e2eRoom = await e2e.getInstanceByRoomId(rid); - return e2eRoom.encrypt({ _id: 'id', msg }); - }, - { rid, msg: messageText }, - ); -}; - const sendEncryptedMessage = async (request: APIRequestContext, rid: string, encryptedMsg: string) => { return request.post(`${BASE_API_URL}/chat.sendMessage`, { headers: { @@ -63,7 +51,7 @@ test.describe('E2EE Legacy Format', () => { await page.goto('/home'); }); - test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { + test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request, api }) => { const channelName = faker.string.uuid(); await injectInitialData(); @@ -80,11 +68,32 @@ test.describe('E2EE Legacy Format', () => { const rid = (await page.locator('[data-qa-rc-room]').getAttribute('data-qa-rc-room')) || ''; expect(rid).toBeTruthy(); - const encryptedMessage = await encryptLegacyMessage(page, rid, 'Old format message'); + const kid = '32c9e7917b78'; + const encryptedKey = + 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; + const encryptedMessage = + '3JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s='; + const response = await api.post('/e2e.updateGroupKey', { + rid, + uid: Users.userE2EE.data._id, + key: kid + encryptedKey, + }); + + expect(response.ok()).toBe(true); + + await page.evaluate( + async ({ rid, kid, encryptedKey }) => { + // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports + const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e'); + const room = await e2e.getInstanceByRoomId(rid); + await room.importGroupKey(kid + encryptedKey); + }, + { rid, kid, encryptedKey }, + ); - await sendEncryptedMessage(request, rid, encryptedMessage); + await sendEncryptedMessage(request, rid, kid + encryptedMessage); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Old format message'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('world'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); }); From bc0fabbe77d78c3d6069757cd4d8f31ee097be3d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 30 Sep 2025 14:14:49 -0300 Subject: [PATCH 191/251] test: remove unneeded api call --- .../tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts index ca4d4103b0ab3..073fea3f3158e 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts @@ -51,7 +51,7 @@ test.describe('E2EE Legacy Format', () => { await page.goto('/home'); }); - test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request, api }) => { + test('legacy expect create a private channel encrypted and send an encrypted message', async ({ page, request }) => { const channelName = faker.string.uuid(); await injectInitialData(); @@ -73,13 +73,6 @@ test.describe('E2EE Legacy Format', () => { 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; const encryptedMessage = '3JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s='; - const response = await api.post('/e2e.updateGroupKey', { - rid, - uid: Users.userE2EE.data._id, - key: kid + encryptedKey, - }); - - expect(response.ok()).toBe(true); await page.evaluate( async ({ rid, kid, encryptedKey }) => { From d5019b5d6e4cb365da0055c3d4603cd10c4dd45a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 30 Sep 2025 16:19:09 -0300 Subject: [PATCH 192/251] test: mobile content compatibility --- .../notification/useDesktopNotification.ts | 3 +- apps/meteor/client/lib/e2ee/aes.ts | 35 +++++ apps/meteor/client/lib/e2ee/content.spec.ts | 139 ++++++++++++++++++ apps/meteor/client/lib/e2ee/content.ts | 68 +++++++++ apps/meteor/client/lib/e2ee/helper.ts | 6 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 112 +++++--------- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 10 +- 7 files changed, 289 insertions(+), 84 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/aes.ts create mode 100644 apps/meteor/client/lib/e2ee/content.spec.ts create mode 100644 apps/meteor/client/lib/e2ee/content.ts diff --git a/apps/meteor/client/hooks/notification/useDesktopNotification.ts b/apps/meteor/client/hooks/notification/useDesktopNotification.ts index bbd5cb740e06b..45b30baaeb791 100644 --- a/apps/meteor/client/hooks/notification/useDesktopNotification.ts +++ b/apps/meteor/client/hooks/notification/useDesktopNotification.ts @@ -28,7 +28,8 @@ export const useDesktopNotification = () => { const e2eRoom = await e2e.getInstanceByRoomId(notification.payload.rid); if (e2eRoom) { const decrypted = await e2eRoom.decrypt(message.content); - notification.text = decrypted.msg ?? decrypted.text; + // TODO(@cardoso): review backward compatibility + notification.text = decrypted.msg ?? ''; } } diff --git a/apps/meteor/client/lib/e2ee/aes.ts b/apps/meteor/client/lib/e2ee/aes.ts new file mode 100644 index 0000000000000..92ead948a6d0e --- /dev/null +++ b/apps/meteor/client/lib/e2ee/aes.ts @@ -0,0 +1,35 @@ +type AesKey = CryptoKey & { algorithm: AesKeyAlgorithm }; + +type AesEncryptedContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +const JWK_ALG_TO_AES_KEY_ALGORITHM: Record = { + A256GCM: { name: 'AES-GCM', length: 256 }, + A128CBC: { name: 'AES-CBC', length: 128 }, +}; + +export const importKey = async (jwk: JsonWebKey): Promise => { + if (!('alg' in jwk) || jwk.alg === undefined) { + throw new Error('JWK alg property is required to import the key'); + } + + const algorithm = JWK_ALG_TO_AES_KEY_ALGORITHM[jwk.alg]; + + if (!algorithm) { + throw new Error(`Unsupported JWK alg: ${jwk.alg}`); + } + + const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['encrypt', 'decrypt']); + return key as AesKey; +}; + +export const decrypt = async (content: AesEncryptedContent, key: AesKey): Promise => { + const decrypted = await crypto.subtle.decrypt( + { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, + key, + content.ciphertext, + ); + return new TextDecoder().decode(decrypted); +}; diff --git a/apps/meteor/client/lib/e2ee/content.spec.ts b/apps/meteor/client/lib/e2ee/content.spec.ts new file mode 100644 index 0000000000000..f2391599afd18 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/content.spec.ts @@ -0,0 +1,139 @@ +import { webcrypto } from 'node:crypto'; + +import { importKey, decrypt } from './aes'; +import { decodeEncryptedContent } from './content'; + +Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); + +const msgv1web = Object.freeze({ + _id: 'JfAxN6Ncsw2XS9eiY', + rid: '68dad82a10815056615446aa', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: '32c9e7917b78LHjHfqLMeDn+2UK1PhD/soFe8CVwvFdLkslcfxNHby4=', + }), + t: 'e2e', + ts: { + $date: '2025-09-29T19:07:07.908Z', + }, + u: { + _id: 'bm4cAAcN92jgXe2jN', + username: 'alice', + name: 'alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-29T19:07:07.929Z', + }, + urls: [], + mentions: [], + channels: [], +}); + +const msgv1mob = Object.freeze({ + _id: 'AZF8Myj605B3f7ZPL', + rid: '68dad82a10815056615446aa', + msg: '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + t: 'e2e', + e2e: 'pending', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: + '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + }), + ts: { + $date: '2025-09-29T19:28:35.261Z', + }, + u: { + _id: 'RQTYT5RJoDKZFwDhk', + username: 'bob', + name: 'bob', + }, + _updatedAt: { + $date: '2025-09-29T19:28:35.274Z', + }, + urls: [], + mentions: [], + channels: [], +}); + +const aeskeyv1 = { alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_ops: ['encrypt', 'decrypt'], kty: 'oct' }; + +test('parse v1 web message', async () => { + const parsed = decodeEncryptedContent(msgv1web.content); + const key = await importKey(aeskeyv1); + const decrypted = await decrypt(parsed, key); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); +}); + +test('parse v1 mobile message', async () => { + const parsed = decodeEncryptedContent(msgv1mob.content); + const key = await importKey(aeskeyv1); + const decrypted = await decrypt(parsed, key); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); +}); + +test('parse v1 mobile message from msg field', async () => { + const parsed = decodeEncryptedContent(msgv1mob.msg); + const key = await importKey(aeskeyv1); + const decrypted = await decrypt(parsed, key); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); +}); + +const msgv2web = Object.freeze({ + _id: 'h6sXWTiKcWfcgkhgo', + rid: '68c9da1b0427bc33b429207e', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v2.aes-sha2', + kid: 'f46d2864-0384-4a87-8815-51fba2cad216', + iv: 'wXbYQ8q9sYRCHtNp', + ciphertext: 'cIDO9mXzCCrrl/wORP0Jf6oWeusqzSCXVGGvY7CHrA==', + }), + t: 'e2e', + ts: { + $date: '2025-09-30T18:05:30.876Z', + }, + u: { + _id: 'Ctk47kkuzJihnmvZE', + username: 'alice', + name: 'Alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-30T18:05:30.887Z', + }, + urls: [], + mentions: [], + channels: [], +}); + +const aeskeyv2 = { + alg: 'A256GCM', + ext: true, + k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', +}; + +test('parse v2 web message', async () => { + const parsed = decodeEncryptedContent(msgv2web.content); + const key = await importKey(aeskeyv2); + const decrypted = await decrypt(parsed, key); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); +}); diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts new file mode 100644 index 0000000000000..97a51a7a297fe --- /dev/null +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -0,0 +1,68 @@ +import { Base64 } from '@rocket.chat/base64'; +import type { EncryptedContent } from '@rocket.chat/core-typings'; + +type DecodedContent = { + kid: string; + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +interface ISlice { + slice(start: number, end?: number): this; +} + +/** + * Splits a slice of data into two parts at the specified index. + * @param data The data to split. + * @param index The index at which to split the data. + * @returns A tuple containing the two parts of the split data. + */ +const split = (data: T, index: number): [T, T] => [data.slice(0, index), data.slice(index)]; + +/** + * Decodes an encrypted content string in the format "kid + base64(iv + ciphertext)". + * @param payload The encrypted content string. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + */ +const decodeV1EncryptedContent = (payload: Extract): DecodedContent => { + const [kid, base64] = split(payload.ciphertext, 12); + const decoded = Base64.decode(base64); + const [iv, ciphertext] = split(decoded, 16); + return { + kid, + iv, + ciphertext, + }; +}; + +/** + * Decodes an encrypted content object with separate fields for kid, iv, and ciphertext. + * @param payload The encrypted content object. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + */ +const decodeV2EncryptedContent = (payload: Extract): DecodedContent => { + const iv = Base64.decode(payload.iv); + const ciphertext = Base64.decode(payload.ciphertext); + return { kid: payload.kid, iv, ciphertext }; +}; + +/** + * Parses encrypted content from either a string or an object and decodes it into its components. + * @param payload The encrypted content, either as a string or an object. + * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. + * @throws Will throw an error if the encryption algorithm is unsupported. + */ +export const decodeEncryptedContent = (payload: string | EncryptedContent): DecodedContent => { + if (typeof payload === 'string') { + payload = { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const; + } + + switch (payload.algorithm) { + case 'rc.v1.aes-sha2': + return decodeV1EncryptedContent(payload); + case 'rc.v2.aes-sha2': + return decodeV2EncryptedContent(payload); + default: + throw new Error(`Unsupported encryption algorithm: ${payload}`); + } +}; diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 4493b4f445ec2..e949f46c75de7 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -37,8 +37,8 @@ export function splitVectorAndEncryptedData( cipherText: Uint8Array, ivLength: 12 | 16, ): { iv: Uint8Array; ciphertext: Uint8Array } { - const iv = cipherText.subarray(0, ivLength); - const ciphertext = cipherText.subarray(ivLength); + const iv = cipherText.slice(0, ivLength); + const ciphertext = cipherText.slice(ivLength); return { iv, ciphertext }; } @@ -74,7 +74,7 @@ async function decryptAesGcm(iv: BufferSource, key: CryptoKey, data: BufferSourc } export function decryptAes(iv: BufferSource, key: CryptoKey, data: BufferSource) { - if (key.algorithm.name === 'AES-GCM') { + if (iv.byteLength === 12) { return decryptAesGcm(iv, key, data); } return decryptAesCbc(iv, key, data); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 16c7401c6a581..6386a567ccf53 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -1,6 +1,14 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { Base64 } from '@rocket.chat/base64'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, AtLeast, EncryptedMessageContent } from '@rocket.chat/core-typings'; +import type { + IE2EEMessage, + IMessage, + IRoom, + ISubscription, + IUser, + AtLeast, + EncryptedMessageContent, + EncryptedContent, +} from '@rocket.chat/core-typings'; import { isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Optional } from '@tanstack/react-query'; @@ -8,15 +16,13 @@ import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; import { decodePrefixedBase64, encodePrefixedBase64 } from './codec'; +import { decodeEncryptedContent } from './content'; import { toString, toArrayBuffer, - // joinVectorAndEncryptedData, - splitVectorAndEncryptedData, encryptRSA, encryptAes, decryptRSA, - // decryptAesGcm, generateAesGcmKey, exportJWKKey, importAesKey, @@ -27,7 +33,6 @@ import { sha256HashFromArrayBuffer, createSha256HashFromText, decryptAes, - joinVectorAndEncryptedData, } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -415,6 +420,7 @@ export class E2ERoom extends Emitter { const key = await importAesKey(JSON.parse(this.sessionKeyExportedString!)); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; + span.info('Group key imported'); } catch (error) { span.set('error', error).error('Error importing group key'); return false; @@ -673,40 +679,9 @@ export class E2ERoom extends Emitter { } as IE2EEMessage; } - // Helper function for encryption of messages - async encrypt(message: IMessage) { - if (!this.isSupportedRoomType(this.typeOfRoom)) { - return; - } - - if (!this.groupSessionKey) { - throw new Error(t('E2E_Invalid_Key')); - } - - const ts = new Date(); - - const data = new TextEncoder().encode( - EJSON.stringify({ - _id: message._id, - text: message.msg, - userId: this.userId, - ts, - }), - ); - - const payload = await this.encryptText(data); - - const iv = Base64.decode(payload.iv); - const ciphertext = Base64.decode(payload.ciphertext); - - const joined = joinVectorAndEncryptedData(iv, ciphertext.buffer); - return `${payload.kid}${Base64.encode(joined)}`; - } - async decryptContent(data: T) { const content = await this.decrypt(data.content); Object.assign(data, content); - return data; } @@ -716,11 +691,11 @@ export class E2ERoom extends Emitter { return message; } - if (message.msg) { + // TODO(@cardoso): review backward compatibility + if (message.msg && !isEncryptedMessageContent(message)) { const data = await this.decrypt(message.msg); - - if (data?.text) { - message.msg = data.text; + if (data.msg) { + message.msg = data.msg; } } @@ -732,40 +707,9 @@ export class E2ERoom extends Emitter { }; } - // Parses the message to extract the keyID and cipherText - parse(payload: string | Required['content']): { - kid: string; - iv: Uint8Array; - ciphertext: Uint8Array; - } { - // v2: {"kid":"...", "iv": "...", "ciphertext":"..."} - if (typeof payload !== 'string' && payload.algorithm === 'rc.v2.aes-sha2') { - const iv = Base64.decode(payload.iv); - const ciphertext = Base64.decode(payload.ciphertext); - - return { kid: payload.kid, iv, ciphertext }; - } - // v1: kid + base64(vector + ciphertext) - const message = typeof payload === 'string' ? payload : payload.ciphertext; - const kidLength = message.includes('-') ? 36 : 12; - const ivLength = kidLength === 36 ? 12 : 16; - const [kid, decoded] = [message.slice(0, kidLength), Base64.decode(message.slice(kidLength))]; - const { iv, ciphertext } = splitVectorAndEncryptedData(decoded, ivLength); - return { - kid, - iv, - ciphertext, - }; - } - - async decrypt( - message: string | Required['content'], - ): Promise< - | { _id: IMessage['_id']; text: string; userId: IUser['_id']; ts: Date; msg?: undefined } - | { _id?: undefined; text?: undefined; userId?: undefined; ts?: undefined; msg: string } - > { + async decrypt(message: string | EncryptedContent): Promise, 'attachments' | 'files' | 'file' | 'msg'>> { const span = log.span('decrypt').set('rid', this.roomId); - const payload = this.parse(message); + const payload = decodeEncryptedContent(message); span.set('payload', payload); const { kid, iv, ciphertext } = payload; @@ -781,9 +725,23 @@ export class E2ERoom extends Emitter { span.set('usages', key.usages.toString()); try { const result = await decryptAes(iv, key, ciphertext); - const ret = EJSON.parse(new TextDecoder('UTF-8').decode(result)); - span.info('decrypted'); - return ret; + const ret: unknown = EJSON.parse(new TextDecoder('UTF-8').decode(result)); + if (typeof ret !== 'object' || ret === null) { + span.error('Decrypted message is not an object'); + return { msg: t('E2E_indecipherable') }; + } + + if ('text' in ret && typeof ret.text === 'string' && !('msg' in ret)) { + const { text, ...rest } = ret; + return { msg: text, ...rest }; + } + + if ('msg' in ret && typeof ret.msg === 'string') { + const { msg, ...rest } = ret; + return { msg, ...rest }; + } + + return { ...ret }; } catch (error) { span.set('error', error).error('Error decrypting message'); return { msg: t('E2E_Key_Error') }; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index b4acc31535160..ecc5cffdbf00a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -169,7 +169,9 @@ class E2E extends Emitter { excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } - await Promise.all(subscriptions.map((sub) => this.onSubscriptionChanged(sub))); + for (const sub of subscriptions) { + void this.onSubscriptionChanged(sub); + } }); } @@ -690,10 +692,12 @@ class E2E extends Emitter { const data = await e2eRoom.decrypt(pinnedMessage.content); - message.attachments[0].text = data.msg ?? data.text; + span.set('data', data); - span.set('decryptedPinnedMessage', message).info('pinned message decrypted'); + // TODO(@cardoso): review backward compatibility + message.attachments[0].text = data.msg; + span.info('pinned message decrypted'); return message; } From f5f8718995d13706b55c4dbe8b70519926f6780a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 30 Sep 2025 18:02:30 -0300 Subject: [PATCH 193/251] chore: move AES logic to dedicated file --- apps/meteor/client/lib/e2ee/aes.ts | 25 +++++- apps/meteor/client/lib/e2ee/helper.ts | 90 ------------------- .../client/lib/e2ee/rocketchat.e2e.room.ts | 45 ++++------ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 +- .../e2ee-encrypted-channels.spec.ts | 9 +- .../page-objects/fragments/home-content.ts | 13 ++- 6 files changed, 57 insertions(+), 129 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/aes.ts b/apps/meteor/client/lib/e2ee/aes.ts index 92ead948a6d0e..ecdf8e80ab1b1 100644 --- a/apps/meteor/client/lib/e2ee/aes.ts +++ b/apps/meteor/client/lib/e2ee/aes.ts @@ -1,4 +1,4 @@ -type AesKey = CryptoKey & { algorithm: AesKeyAlgorithm }; +export type Key = CryptoKey & { algorithm: AesKeyAlgorithm }; type AesEncryptedContent = { iv: Uint8Array; @@ -10,7 +10,7 @@ const JWK_ALG_TO_AES_KEY_ALGORITHM: Record = { A128CBC: { name: 'AES-CBC', length: 128 }, }; -export const importKey = async (jwk: JsonWebKey): Promise => { +export const importKey = async (jwk: JsonWebKey): Promise => { if (!('alg' in jwk) || jwk.alg === undefined) { throw new Error('JWK alg property is required to import the key'); } @@ -22,10 +22,20 @@ export const importKey = async (jwk: JsonWebKey): Promise => { } const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['encrypt', 'decrypt']); - return key as AesKey; + return key as Key; }; -export const decrypt = async (content: AesEncryptedContent, key: AesKey): Promise => { +export const exportKey = (key: Key): Promise => { + return crypto.subtle.exportKey('jwk', key); +}; + +export const generateKey = async (): Promise => { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + + return key as Key; +}; + +export const decrypt = async (content: AesEncryptedContent, key: Key): Promise => { const decrypted = await crypto.subtle.decrypt( { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, key, @@ -33,3 +43,10 @@ export const decrypt = async (content: AesEncryptedContent, key: AesKey): Promis ); return new TextDecoder().decode(decrypted); }; + +export const encrypt = async (plaintext: Uint8Array, key: Key): Promise => { + const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16; + const iv = crypto.getRandomValues(new Uint8Array(ivLength)); + const ciphertext = await crypto.subtle.encrypt({ name: key.algorithm.name, iv }, key, plaintext); + return { iv, ciphertext: new Uint8Array(ciphertext) }; +}; diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index e949f46c75de7..8d27908b31bd7 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -25,68 +25,10 @@ export function toArrayBuffer(thing: any) { return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); } -export function joinVectorAndEncryptedData(vector: Uint8Array, encryptedData: ArrayBuffer) { - const cipherText = new Uint8Array(encryptedData); - const output = new Uint8Array(vector.length + cipherText.length); - output.set(vector, 0); - output.set(cipherText, vector.length); - return output; -} - -export function splitVectorAndEncryptedData( - cipherText: Uint8Array, - ivLength: 12 | 16, -): { iv: Uint8Array; ciphertext: Uint8Array } { - const iv = cipherText.slice(0, ivLength); - const ciphertext = cipherText.slice(ivLength); - return { iv, ciphertext }; -} - export async function encryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); } -/** - * Encrypts data using AES-CBC. - * @param iv The initialization vector. - * @param key The encryption key. - * @param data The data to encrypt. - * @returns The encrypted data. - * @deprecated Use {@link encryptAesGcm} instead. - */ -export async function encryptAesCbc(iv: BufferSource, key: CryptoKey, data: BufferSource) { - const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data); - return encrypted; -} - -export async function decryptAesCbc(iv: BufferSource, key: CryptoKey, data: BufferSource) { - return crypto.subtle.decrypt({ name: 'AES-CBC', iv }, key, data); -} - -async function encryptAesGcm(iv: BufferSource, key: CryptoKey, data: BufferSource) { - const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); - return encrypted; -} - -async function decryptAesGcm(iv: BufferSource, key: CryptoKey, data: BufferSource) { - const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data); - return decrypted; -} - -export function decryptAes(iv: BufferSource, key: CryptoKey, data: BufferSource) { - if (iv.byteLength === 12) { - return decryptAesGcm(iv, key, data); - } - return decryptAesCbc(iv, key, data); -} - -export function encryptAes(iv: BufferSource, key: CryptoKey, data: BufferSource) { - if (key.algorithm.name === 'AES-GCM') { - return encryptAesGcm(iv, key, data); - } - return encryptAesCbc(iv, key, data); -} - export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } @@ -95,18 +37,6 @@ export async function decryptRSA(key: CryptoKey, data: BufferSource) { return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); } -/** - * Generates an AES-CBC key. - * @deprecated Use {@link generateAesGcmKey} instead. - */ -export async function generateAesCbcKey() { - return crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']); -} - -export function generateAesGcmKey(): Promise { - return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); -} - export function generateAESCTRKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } @@ -141,26 +71,6 @@ export function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - return crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages); -} - -/** - * Imports an AES-GCM key from JWK format. - */ -export function importAesKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - const isCBC = keyData.alg === 'A128CBC' || keyData.alg === 'A192CBC' || keyData.alg === 'A256CBC'; - if (isCBC) { - console.warn('Importing an AES-CBC key. Consider migrating to AES-GCM for better security.'); - return importAesCbcKey(keyData, keyUsages); - } - return crypto.subtle.importKey('jwk', keyData, { name: 'AES-GCM' }, true, keyUsages); -} - export function readFileAsArrayBuffer(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 6386a567ccf53..0fcc8665c3cec 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -15,24 +15,20 @@ import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; +import * as Aes from './aes'; import { decodePrefixedBase64, encodePrefixedBase64 } from './codec'; import { decodeEncryptedContent } from './content'; import { toString, toArrayBuffer, encryptRSA, - encryptAes, decryptRSA, - generateAesGcmKey, - exportJWKKey, - importAesKey, importRSAKey, readFileAsArrayBuffer, encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText, - decryptAes, } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -94,9 +90,9 @@ export class E2ERoom extends Emitter { roomKeyId: string | undefined; - groupSessionKey: CryptoKey | null = null; + groupSessionKey: Aes.Key | null = null; - oldKeys: { E2EKey: CryptoKey | null; ts: Date; e2eKeyId: string }[] | undefined; + oldKeys: { E2EKey: Aes.Key | null; ts: Date; e2eKeyId: string }[] | undefined; sessionKeyExportedString: string | undefined; @@ -366,7 +362,7 @@ export class E2ERoom extends Emitter { } async decryptSessionKey(key: string) { - return importAesKey(JSON.parse(await this.exportSessionKey(key))); + return Aes.importKey(JSON.parse(await this.exportSessionKey(key))); } async exportSessionKey(key: string) { @@ -417,7 +413,7 @@ export class E2ERoom extends Emitter { // Import session key for use. try { - const key = await importAesKey(JSON.parse(this.sessionKeyExportedString!)); + const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString!)); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; span.info('Group key imported'); @@ -430,9 +426,8 @@ export class E2ERoom extends Emitter { } async createNewGroupKey() { - this.groupSessionKey = await generateAesGcmKey(); - - const sessionKeyExported = await exportJWKKey(this.groupSessionKey!); + this.groupSessionKey = await Aes.generateKey(); + const sessionKeyExported = await Aes.exportKey(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); this.keyID = crypto.randomUUID(); } @@ -628,23 +623,15 @@ export class E2ERoom extends Emitter { if (!this.groupSessionKey) { throw new Error('No group session key found.'); } - const isAesCbc = this.groupSessionKey.algorithm.name === 'AES-CBC'; - if (isAesCbc) { - span.warn('Using deprecated AES-CBC algorithm for encryption. Please upgrade to AES-GCM.'); - } - // AES-GCM uses a 12 byte vector, while AES-CBC uses a 16 byte vector - // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#parameters - // https://www.rfc-editor.org/rfc/rfc5116.html#section-3.1 - // https://tools.ietf.org/html/rfc3602#section-2 - const vector = crypto.getRandomValues(new Uint8Array(isAesCbc ? 16 : 12)); - const result = await encryptAes(vector, this.groupSessionKey, data); - const ciphertext = { + + const { iv, ciphertext } = await Aes.encrypt(data, this.groupSessionKey); + const encryptedData = { kid: this.keyID, - iv: Base64.encode(vector), - ciphertext: Base64.encode(new Uint8Array(result)), + iv: Base64.encode(iv), + ciphertext: Base64.encode(ciphertext), }; span.set('ciphertext', ciphertext).info('message encrypted'); - return ciphertext; + return encryptedData; } catch (error) { span.set('error', error).error('Error encrypting message'); throw error; @@ -724,8 +711,8 @@ export class E2ERoom extends Emitter { span.set('type', key.type); span.set('usages', key.usages.toString()); try { - const result = await decryptAes(iv, key, ciphertext); - const ret: unknown = EJSON.parse(new TextDecoder('UTF-8').decode(result)); + const result = await Aes.decrypt({ iv, ciphertext }, key); + const ret: unknown = EJSON.parse(result); if (typeof ret !== 'object' || ret === null) { span.error('Decrypted message is not an object'); return { msg: t('E2E_indecipherable') }; @@ -748,7 +735,7 @@ export class E2ERoom extends Emitter { } } - private retrieveDecryptionKey(kid: string): CryptoKey | null { + private retrieveDecryptionKey(kid: string): Aes.Key | null { const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === kid); return oldRoomKey?.E2EKey ?? this.groupSessionKey; } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index ecc5cffdbf00a..b339186ac4db5 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -11,7 +11,7 @@ import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; -import { splitVectorAndEncryptedData, generateRSAKey, exportJWKKey, importRSAKey, generatePassphrase } from './helper'; +import { generateRSAKey, exportJWKKey, importRSAKey, generatePassphrase } from './helper'; import { createLogger } from './logger'; import { getMasterKey, type Pbkdf2Options } from './pbkdf2'; import { E2ERoom } from './rocketchat.e2e.room'; @@ -606,7 +606,7 @@ class E2E extends Emitter { if ('$binary' in json && typeof json.$binary === 'string') { // v1: { $binary: base64(iv[16] + ciphertext) } const binary = Base64.decode(json.$binary); - const { iv, ciphertext } = splitVectorAndEncryptedData(binary, 16); + const [iv, ciphertext] = [binary.slice(0, 16), binary.slice(16)]; return { iv, ciphertext, iterations: 1000, salt: userId }; } diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts index 56ab640f9ff3a..2486c5da8a834 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-encrypted-channels.spec.ts @@ -143,7 +143,10 @@ test.describe('E2EE Encrypted Channels', () => { await poHomeChannel.dismissToast(); await poHomeChannel.tabs.kebab.click(); - await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await poHomeChannel.tabs.btnEnableE2E.isVisible())) { + await poHomeChannel.tabs.kebab.click(); + } await poHomeChannel.tabs.btnEnableE2E.click(); await expect(page.getByRole('dialog', { name: 'Enable encryption' })).toBeVisible(); await page.getByRole('button', { name: 'Enable encryption' }).click(); @@ -258,6 +261,10 @@ test.describe('E2EE Encrypted Channels', () => { // Delete last message await expect(poHomeChannel.content.lastUserMessageBody).toHaveText(encriptedMessage2); await poHomeChannel.content.openLastMessageMenu(); + // TODO(@jessicaschelly/@dougfabris): fix this flaky behavior + if (!(await page.locator('role=menuitem[name="Delete"]').isVisible())) { + await poHomeChannel.content.openLastMessageMenu(); + } await page.locator('role=menuitem[name="Delete"]').click(); await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index f767c9321245a..1b9bae8371730 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -1,9 +1,16 @@ import fs from 'fs/promises'; +import { resolve, join, relative } from 'node:path'; import type { Locator, Page } from '@playwright/test'; import { expect } from '../../utils/test'; +const FIXTURES_PATH = relative(process.cwd(), resolve(__dirname, '../../fixtures/files')); + +export function getFilePath(fileName: string): string { + return join(FIXTURES_PATH, fileName); +} + export class HomeContent { protected readonly page: Page; @@ -379,7 +386,7 @@ export class HomeContent { } async dragAndDropTxtFile(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { @@ -395,7 +402,7 @@ export class HomeContent { } async dragAndDropLstFile(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/lst-test.lst', 'utf-8'); + const contract = await fs.readFile(getFilePath('lst-test.lst'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'lst-test.lst', { @@ -411,7 +418,7 @@ export class HomeContent { } async dragAndDropTxtFileToThread(): Promise { - const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const contract = await fs.readFile(getFilePath('any_file.txt'), 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { const data = new DataTransfer(); const file = new File([`${contract}`], 'any_file.txt', { From 85822f63ea698fc2c96874dec8166ce00204d1b9 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 30 Sep 2025 18:39:01 -0300 Subject: [PATCH 194/251] chore: move RSA logic to separate file --- apps/meteor/client/lib/e2ee/helper.ts | 38 ------------ .../client/lib/e2ee/rocketchat.e2e.room.ts | 20 +++--- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 17 +++--- apps/meteor/client/lib/e2ee/rsa.ts | 61 +++++++++++++++++++ 4 files changed, 79 insertions(+), 57 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/rsa.ts diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 8d27908b31bd7..285725fef92ff 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -25,52 +25,14 @@ export function toArrayBuffer(thing: any) { return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); } -export async function encryptRSA(key: CryptoKey, data: BufferSource) { - return crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data); -} - export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } -export async function decryptRSA(key: CryptoKey, data: BufferSource) { - return crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data); -} - export function generateAESCTRKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } -export function generateRSAKey(): Promise { - return crypto.subtle.generateKey( - { - name: 'RSA-OAEP', - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: { name: 'SHA-256' }, - }, - true, - ['encrypt', 'decrypt'], - ); -} - -export function exportJWKKey(key: CryptoKey): Promise { - return crypto.subtle.exportKey('jwk', key); -} - -export function importRSAKey(keyData: JsonWebKey, keyUsages: ReadonlyArray = ['encrypt', 'decrypt']) { - return crypto.subtle.importKey( - 'jwk', - keyData, - { - name: 'RSA-OAEP', - hash: { name: 'SHA-256' }, - }, - true, - keyUsages, - ); -} - export function readFileAsArrayBuffer(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 0fcc8665c3cec..005362744ad0c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -21,9 +21,6 @@ import { decodeEncryptedContent } from './content'; import { toString, toArrayBuffer, - encryptRSA, - decryptRSA, - importRSAKey, readFileAsArrayBuffer, encryptAESCTR, generateAESCTRKey, @@ -32,6 +29,7 @@ import { } from './helper'; import { createLogger } from './logger'; import { e2e } from './rocketchat.e2e'; +import * as Rsa from './rsa'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; @@ -377,7 +375,7 @@ export class E2ERoom extends Emitter { throw new Error('Private key not found'); } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); span.info('Key decrypted'); return toString(decryptedKey); } @@ -398,7 +396,7 @@ export class E2ERoom extends Emitter { if (!e2e.privateKey) { throw new Error('Private key not found'); } - const decryptedKey = await decryptRSA(e2e.privateKey, decodedKey); + const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); this.sessionKeyExportedString = toString(decryptedKey); } catch (error) { span.set('error', error).error('Error decrypting group key'); @@ -541,7 +539,7 @@ export class E2ERoom extends Emitter { let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await Rsa.importPublicKey(JSON.parse(publicKey)); } catch (error) { return span.set('error', error).error('Error importing user key'); } @@ -552,8 +550,8 @@ export class E2ERoom extends Emitter { if (!oldRoomKey.E2EKey) { continue; } - const encryptedKey = await encryptRSA(userKey, toArrayBuffer(oldRoomKey.E2EKey)); - const encryptedKeyToString = encodePrefixedBase64(oldRoomKey.e2eKeyId, new Uint8Array(encryptedKey)); + const encryptedKey = await Rsa.encrypt(userKey, toArrayBuffer(oldRoomKey.E2EKey)); + const encryptedKeyToString = encodePrefixedBase64(oldRoomKey.e2eKeyId, encryptedKey); keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); } @@ -567,15 +565,15 @@ export class E2ERoom extends Emitter { const span = log.span('encryptGroupKeyForParticipant'); let userKey; try { - userKey = await importRSAKey(JSON.parse(publicKey), ['encrypt']); + userKey = await Rsa.importPublicKey(JSON.parse(publicKey)); } catch (error) { return span.set('error', error).error('Error importing user key'); } // Encrypt session key for this user with his/her public key try { - const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); - const encryptedUserKeyToString = encodePrefixedBase64(this.keyID, new Uint8Array(encryptedUserKey)); + const encryptedUserKey = await Rsa.encrypt(userKey, toArrayBuffer(this.sessionKeyExportedString)); + const encryptedUserKeyToString = encodePrefixedBase64(this.keyID, encryptedUserKey); span.info('Group key encrypted for participant'); return encryptedUserKeyToString; } catch (error) { diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index b339186ac4db5..eda14608f086a 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -11,10 +11,11 @@ import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; -import { generateRSAKey, exportJWKKey, importRSAKey, generatePassphrase } from './helper'; +import { generatePassphrase } from './helper'; import { createLogger } from './logger'; import { getMasterKey, type Pbkdf2Options } from './pbkdf2'; import { E2ERoom } from './rocketchat.e2e.room'; +import * as Rsa from './rsa'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; import { getUserAvatarURL } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; @@ -51,7 +52,7 @@ class E2E extends Emitter { private db_private_key: string | null | undefined; - public privateKey: CryptoKey | undefined; + public privateKey: Rsa.PrivateKey | undefined; public publicKey: string | undefined; @@ -444,7 +445,7 @@ class E2E extends Emitter { this.publicKey = public_key; try { - this.privateKey = await importRSAKey(JSON.parse(private_key), ['decrypt']); + this.privateKey = await Rsa.importPrivateKey(JSON.parse(private_key)); Accounts.storageLocation.setItem('private_key', private_key); } catch (error) { @@ -457,17 +458,17 @@ class E2E extends Emitter { const span = log.span('createAndLoadKeys'); // Could not obtain public-private keypair from server. this.setState('LOADING_KEYS'); - let key; + let keyPair; try { - key = await generateRSAKey(); - this.privateKey = key.privateKey; + keyPair = await Rsa.generateKeyPair(); + this.privateKey = keyPair.privateKey; } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error generating key'); } try { - const publicKey = await exportJWKKey(key.publicKey); + const publicKey = await Rsa.exportKey(keyPair.publicKey); this.publicKey = JSON.stringify(publicKey); Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); @@ -477,7 +478,7 @@ class E2E extends Emitter { } try { - const privateKey = await exportJWKKey(key.privateKey); + const privateKey = await Rsa.exportKey(keyPair.privateKey); Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { diff --git a/apps/meteor/client/lib/e2ee/rsa.ts b/apps/meteor/client/lib/e2ee/rsa.ts new file mode 100644 index 0000000000000..7966c51eb4e78 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/rsa.ts @@ -0,0 +1,61 @@ +export type Key = CryptoKey & { algorithm: RsaKeyAlgorithm }; +export type PrivateKey = Key & { type: 'private'; usages: ['decrypt'] }; +export type PublicKey = Key & { type: 'public'; usages: ['encrypt'] }; +export type KeyPair = CryptoKeyPair & { publicKey: PublicKey; privateKey: PrivateKey }; + +export const generateKeyPair = async (): Promise => { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + } satisfies RsaHashedKeyGenParams, + true, + ['encrypt', 'decrypt'], + ); + + return keyPair as KeyPair; +}; + +export const exportKey = (key: Key): Promise => { + return crypto.subtle.exportKey('jwk', key); +}; + +export const importPrivateKey = async (keyData: JsonWebKey): Promise => { + const key = await crypto.subtle.importKey( + 'jwk', + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }, + true, + ['decrypt'], + ); + return key as PrivateKey; +}; + +export const importPublicKey = async (keyData: JsonWebKey): Promise => { + const key = await crypto.subtle.importKey( + 'jwk', + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt'], + ); + return key as PublicKey; +}; + +export const encrypt = async (key: PublicKey, data: BufferSource): Promise> => { + const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); + return new Uint8Array(encrypted); +}; + +export const decrypt = async (key: PrivateKey, data: BufferSource): Promise> => { + const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); + return new Uint8Array(decrypted); +}; From a1d4318afd4cefbb97ff65179bf799db923d6e62 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 1 Oct 2025 18:30:48 -0300 Subject: [PATCH 195/251] fix: file upload --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 7 +++---- .../tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index eda14608f086a..1a78bcdd3c3e3 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -238,12 +238,11 @@ class E2E extends Emitter { }); } - async getInstanceByRoomId(rid: IRoom['_id']): Promise { + async getInstanceByRoomId(rid: IRoom['_id']): Promise { const room = await this.waitForRoom(rid); - const userId = this.getUserId(); - if (!this.instancesByRoomId[rid] && userId) { - this.instancesByRoomId[rid] = new E2ERoom(userId, room); + if (!this.instancesByRoomId[rid] && this.userId) { + this.instancesByRoomId[rid] = new E2ERoom(this.userId, room); } // When the key was already set and is changed via an update, we update the room instance diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts index 073fea3f3158e..fcd3c11010fc1 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-legacy-format.spec.ts @@ -79,7 +79,7 @@ test.describe('E2EE Legacy Format', () => { // eslint-disable-next-line import/no-unresolved, @typescript-eslint/no-var-requires, import/no-absolute-path, @typescript-eslint/consistent-type-imports const { e2e } = require('/client/lib/e2ee/rocketchat.e2e.ts') as typeof import('../../../client/lib/e2ee/rocketchat.e2e'); const room = await e2e.getInstanceByRoomId(rid); - await room.importGroupKey(kid + encryptedKey); + await room?.importGroupKey(kid + encryptedKey); }, { rid, kid, encryptedKey }, ); From beecd4b547d90918ad25c4ad3bc66db3065cb184 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 2 Oct 2025 10:49:17 -0300 Subject: [PATCH 196/251] fix: file upload (2) --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 1a78bcdd3c3e3..1cd236aa18aca 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -247,7 +247,7 @@ class E2E extends Emitter { // When the key was already set and is changed via an update, we update the room instance if ( - this.instancesByRoomId[rid].keyID !== undefined && + this.instancesByRoomId[rid]?.keyID !== undefined && room.e2eKeyId !== undefined && this.instancesByRoomId[rid].keyID !== room.e2eKeyId ) { From 40d958767a59f0606038b59dcd8f795af5ba488d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 2 Oct 2025 12:33:24 -0300 Subject: [PATCH 197/251] fix: getInstanceByRoomId --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 1cd236aa18aca..4320c78dd4631 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -238,16 +238,28 @@ class E2E extends Emitter { }); } - async getInstanceByRoomId(rid: IRoom['_id']): Promise { + async getInstanceByRoomId(rid: IRoom['_id']): Promise { + if (!this.userId) { + return null; + } + const room = await this.waitForRoom(rid); + if (room.t !== 'd' && room.t !== 'p') { + return null; + } + + if (!room.encrypted) { + return null; + } + if (!this.instancesByRoomId[rid] && this.userId) { this.instancesByRoomId[rid] = new E2ERoom(this.userId, room); } // When the key was already set and is changed via an update, we update the room instance if ( - this.instancesByRoomId[rid]?.keyID !== undefined && + this.instancesByRoomId[rid].keyID !== undefined && room.e2eKeyId !== undefined && this.instancesByRoomId[rid].keyID !== room.e2eKeyId ) { @@ -255,7 +267,7 @@ class E2E extends Emitter { this.instancesByRoomId[rid].onRoomKeyReset(room.e2eKeyId); } - return this.instancesByRoomId[rid]; + return this.instancesByRoomId[rid] ?? null; } removeInstanceByRoomId(rid: IRoom['_id']): void { From 453d99a171a8d7287de0c6fc8e6dee15067ae98c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 4 Oct 2025 17:50:41 -0300 Subject: [PATCH 198/251] fix: remove some span attributes --- apps/meteor/client/lib/e2ee/README.md | 19 -------- apps/meteor/client/lib/e2ee/content.ts | 2 +- apps/meteor/client/lib/e2ee/docs/HISTORY.md | 46 ------------------- .../meteor/client/lib/e2ee/docs/REFERENCES.md | 4 -- .../client/lib/e2ee/rocketchat.e2e.room.ts | 11 ++--- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 4 +- 6 files changed, 7 insertions(+), 79 deletions(-) delete mode 100644 apps/meteor/client/lib/e2ee/README.md delete mode 100644 apps/meteor/client/lib/e2ee/docs/HISTORY.md delete mode 100644 apps/meteor/client/lib/e2ee/docs/REFERENCES.md diff --git a/apps/meteor/client/lib/e2ee/README.md b/apps/meteor/client/lib/e2ee/README.md deleted file mode 100644 index 12d4444de2e34..0000000000000 --- a/apps/meteor/client/lib/e2ee/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# End-to-End Encryption - -## 1. Summary - -### 1.1 User Identity & Master Key -A BIP39 Mnemonic (from high-entropy randomness) is passed through PBKDF2 (with a salt and high iteration count) to create a strong, memorable master key. - -### 1.2 Long-Term Identity Keys -A long-term RSA key pair is generated randomly. The private key is then encrypted with the master key for secure server backup and recovery. The public key is shared with others. - -### 1.3 Message Encryption -For any given chat, a symmetric AES-GCM key is generated. This key is used to encrypt the actual messages because it's extremely fast and efficient. - -### 1.4 Key Distribution & Group Management -When a member needs the AES key (either to start a chat or join a group), an existing member encrypts it using the recipient's Public RSA key. - -### 1.5 Forward Secrecy (NOT implemented) -Whenever a group's membership changes (a user is added or removed), a completely new AES-GCM key is generated and securely distributed to all current members. This protects the integrity of the conversation going forward. - diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts index 97a51a7a297fe..501e6c940d708 100644 --- a/apps/meteor/client/lib/e2ee/content.ts +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -63,6 +63,6 @@ export const decodeEncryptedContent = (payload: string | EncryptedContent): Deco case 'rc.v2.aes-sha2': return decodeV2EncryptedContent(payload); default: - throw new Error(`Unsupported encryption algorithm: ${payload}`); + throw new Error('Unsupported algorithm'); } }; diff --git a/apps/meteor/client/lib/e2ee/docs/HISTORY.md b/apps/meteor/client/lib/e2ee/docs/HISTORY.md deleted file mode 100644 index c6638ad70bdaa..0000000000000 --- a/apps/meteor/client/lib/e2ee/docs/HISTORY.md +++ /dev/null @@ -1,46 +0,0 @@ - -## History -- [[NEW] Support for end to end encryption](https://github.com/RocketChat/Rocket.Chat/pull/10094) -- [Fix: Change wording on e2e to make a little more clear](https://github.com/RocketChat/Rocket.Chat/pull/12124) -- [Fix: e2e password visible on always-on alert message](https://github.com/RocketChat/Rocket.Chat/pull/12139) -- [New: Option to change E2E key](https://github.com/RocketChat/Rocket.Chat/pull/12169) -- [Improve: Moved the e2e password request to an alert instead of a popup](https://github.com/RocketChat/Rocket.Chat/pull/12172) -- [Improve: Decrypt last message](https://github.com/RocketChat/Rocket.Chat/pull/12173) -- [Improve: Rename E2E methods](https://github.com/RocketChat/Rocket.Chat/pull/12175) -- [Improve: Do not start E2E Encryption when accessing admin as embedded](https://github.com/RocketChat/Rocket.Chat/pull/12192) -- [[FIX] E2E data not cleared on logout](https://github.com/RocketChat/Rocket.Chat/pull/12254) -- [[NEW] Option to reset e2e key](https://github.com/RocketChat/Rocket.Chat/pull/12483) -- [Remove directly dependency between lib and e2e](https://github.com/RocketChat/Rocket.Chat/pull/13115) -- [[FIX] E2E messages not decrypting in message threads](https://github.com/RocketChat/Rocket.Chat/pull/14580) -- [[FIX] Not possible to read encrypted messages after disable E2E on channel level](https://github.com/RocketChat/Rocket.Chat/pull/18101) -- [[NEW] Encrypted Discussions and new Encryption Permissions](https://github.com/RocketChat/Rocket.Chat/pull/20201) -- [[FIX] E2E issues](https://github.com/RocketChat/Rocket.Chat/pull/20704) -- [[FIX] Ensure E2E is enabled/disabled on sending message](https://github.com/RocketChat/Rocket.Chat/pull/21084) -- [Regression: Fix non encrypted rooms failing sending messages](https://github.com/RocketChat/Rocket.Chat/pull/21287) -- [Chore: Replace `promises` helper](https://github.com/RocketChat/Rocket.Chat/pull/23488) -- [[NEW] E2E password generator](github.com/RocketChat/Rocket.Chat/pull/24114) -- [[IMPROVE] Quotes on E2EE Messages](https://github.com/RocketChat/Rocket.Chat/pull/26303) -- [[IMPROVE] Require acceptance when setting new E2E Encryption key for another user](https://github.com/RocketChat/Rocket.Chat/pull/27556) -- [refactor: Replace `Notifications` in favor of `sdk.stream`;](https://github.com/RocketChat/Rocket.Chat/issues/31409) -- [chore: don't `ignoreUndefined`](https://github.com/RocketChat/Rocket.Chat/pull/31497) -- [feat: Un-encrypted messages not allowed in E2EE rooms](https://github.com/RocketChat/Rocket.Chat/pull/32040) -- [feat(E2EE): Async E2EE keys exchange](https://github.com/RocketChat/Rocket.Chat/pull/32197) -- [feat(E2EEncryption): File encryption support](https://github.com/RocketChat/Rocket.Chat/pull/32316) -- [regression(E2EEncryption): Service Worker not installing on first load](https://github.com/RocketChat/Rocket.Chat/pull/32674) -- [fix: Disabled E2EE room instances creation](https://github.com/RocketChat/Rocket.Chat/pull/32857) -- [feat!: Allow E2EE rooms to reset its room key](https://github.com/RocketChat/Rocket.Chat/pull/33328) -- [refactor!: Room's Key ID generation](https://github.com/RocketChat/Rocket.Chat/pull/33329) -- [fix: Server not notifying users of E2EE key suggestions](https://github.com/RocketChat/Rocket.Chat/pull/33435) -- [feat: E2EE room key reset modal](https://github.com/RocketChat/Rocket.Chat/pull/33503) -- [chore: Upgrade `@tanstack/query` to v5](https://github.com/RocketChat/Rocket.Chat/pull/33898) -- [fix: Allow any user in e2ee room to create and propagate room keys](https://github.com/RocketChat/Rocket.Chat/pull/34038) -- [chore: bump meteor and node version](https://github.com/RocketChat/Rocket.Chat/pull/) -- [chore: Update types of `e2e.room` file](https://github.com/RocketChat/Rocket.Chat/pull/34944) -- [fix: Quote chaining in E2EE room not limiting quote depth](https://github.com/RocketChat/Rocket.Chat/pull/36143) -- [fix: stale data after login expired](https://github.com/RocketChat/Rocket.Chat/pull/36338) -- [chore: Rooms store](https://github.com/RocketChat/Rocket.Chat/pull/36439) -- [chore(deps-dev): bump typescript to 5.9.2](https://github.com/RocketChat/Rocket.Chat/pull/36645) -- [chore: Move E2E Encryption startup](https://github.com/RocketChat/Rocket.Chat/pull/36722) -- [feat: Allow reset E2E key from `EnterE2EPassword` modal](https://github.com/RocketChat/Rocket.Chat/pull/36778) -- [feat: Improve E2E encryption UI texts](https://github.com/RocketChat/Rocket.Chat/pull/36923) -- [feat: Remove e2ee config from edit room panel](https://github.com/RocketChat/Rocket.Chat/pull/36945) diff --git a/apps/meteor/client/lib/e2ee/docs/REFERENCES.md b/apps/meteor/client/lib/e2ee/docs/REFERENCES.md deleted file mode 100644 index 53e7c51047ead..0000000000000 --- a/apps/meteor/client/lib/e2ee/docs/REFERENCES.md +++ /dev/null @@ -1,4 +0,0 @@ -- [RFC7696](https://datatracker.ietf.org/doc/html/rfc7696/) -- [RFC8152](https://datatracker.ietf.org/doc/html/rfc8152/) -- [RFC8452](https://datatracker.ietf.org/doc/html/rfc8452/) -- [RFC9709](https://datatracker.ietf.org/doc/html/rfc9709/) \ No newline at end of file diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 005362744ad0c..11b2ca852cfa1 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -257,10 +257,10 @@ export class E2ERoom extends Emitter { ...key, E2EKey: k, }); - } catch (e) { - span.set('error', e); + } catch (error) { span.error( `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + error, ); keys.push({ ...key, E2EKey: null }); } @@ -364,8 +364,7 @@ export class E2ERoom extends Emitter { } async exportSessionKey(key: string) { - const span = log.span('exportSessionKey').set('key', key); - // The length of a base64 encoded block of 256 bytes is always 344 characters + const span = log.span('exportSessionKey'); const [prefix, decodedKey] = decodePrefixedBase64(key); span.set('prefix', prefix); @@ -628,10 +627,10 @@ export class E2ERoom extends Emitter { iv: Base64.encode(iv), ciphertext: Base64.encode(ciphertext), }; - span.set('ciphertext', ciphertext).info('message encrypted'); + span.info('message encrypted'); return encryptedData; } catch (error) { - span.set('error', error).error('Error encrypting message'); + span.error('Error encrypting message', error); throw error; } } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 4320c78dd4631..d65a27f02873b 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -687,7 +687,7 @@ class E2E extends Emitter { } async decryptPinnedMessage(message: IE2EEPinnedMessage) { - const span = log.span('decryptPinnedMessage').set('message', message); + const span = log.span('decryptPinnedMessage'); const [pinnedMessage] = message.attachments; if (!pinnedMessage) { @@ -704,8 +704,6 @@ class E2E extends Emitter { const data = await e2eRoom.decrypt(pinnedMessage.content); - span.set('data', data); - // TODO(@cardoso): review backward compatibility message.attachments[0].text = data.msg; From c668f9af9c078b847edaf0be36a555b8c76c7f0e Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 5 Oct 2025 15:45:49 -0300 Subject: [PATCH 199/251] fix: add more validation --- apps/meteor/client/lib/e2ee/content.ts | 32 +++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts index 501e6c940d708..bc68a1c3e41be 100644 --- a/apps/meteor/client/lib/e2ee/content.ts +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -24,10 +24,22 @@ const split = (data: T, index: number): [T, T] => [data.slice( * @param payload The encrypted content string. * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. */ -const decodeV1EncryptedContent = (payload: Extract): DecodedContent => { +const decodeV1EncryptedContent = ( + payload: Omit, 'algorithm'>, +): DecodedContent => { + if (payload.ciphertext.length < 12) { + throw new Error('Invalid v1 ciphertext: too short for kid extraction'); + } + const [kid, base64] = split(payload.ciphertext, 12); const decoded = Base64.decode(base64); + + if (decoded.length < 16) { + throw new Error('Invalid v1 ciphertext: too short for iv extraction'); + } + const [iv, ciphertext] = split(decoded, 16); + return { kid, iv, @@ -46,6 +58,14 @@ const decodeV2EncryptedContent = (payload: Extract { + if (typeof payload === 'string') { + return { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const; + } + + return payload; +}; + /** * Parses encrypted content from either a string or an object and decodes it into its components. * @param payload The encrypted content, either as a string or an object. @@ -53,16 +73,16 @@ const decodeV2EncryptedContent = (payload: Extract { - if (typeof payload === 'string') { - payload = { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const; - } + payload = normalizePayload(payload); + + const { algorithm } = payload; - switch (payload.algorithm) { + switch (algorithm) { case 'rc.v1.aes-sha2': return decodeV1EncryptedContent(payload); case 'rc.v2.aes-sha2': return decodeV2EncryptedContent(payload); default: - throw new Error('Unsupported algorithm'); + throw new Error(`Unsupported encryption algorithm: ${algorithm}`); } }; From ce515986205888295273f085a35b6012c1208a13 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sun, 5 Oct 2025 16:09:50 -0300 Subject: [PATCH 200/251] fix: add validation to v2 payload decoding --- apps/meteor/client/lib/e2ee/content.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts index bc68a1c3e41be..b051434c7ee1e 100644 --- a/apps/meteor/client/lib/e2ee/content.ts +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -53,6 +53,10 @@ const decodeV1EncryptedContent = ( * @returns An object containing the key ID (kid), initialization vector (iv), and ciphertext. */ const decodeV2EncryptedContent = (payload: Extract): DecodedContent => { + if (!payload.kid || !payload.iv || !payload.ciphertext) { + throw new Error('Invalid v2 payload: kid, iv, and ciphertext must be non-empty'); + } + const iv = Base64.decode(payload.iv); const ciphertext = Base64.decode(payload.ciphertext); return { kid: payload.kid, iv, ciphertext }; From c0801bfed23349f9251f3a729bf854de540f9c1d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 11 Oct 2025 15:35:40 -0300 Subject: [PATCH 201/251] fix: support v2 format in chat.update endpoint --- packages/rest-typings/src/v1/Ajv.ts | 1 + packages/rest-typings/src/v1/chat.ts | 44 ++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index 35e3d57878fd7..0c2def3db310f 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -5,6 +5,7 @@ const ajv = new Ajv({ coerceTypes: true, allowUnionTypes: true, code: { source: true }, + discriminator: true, }); // TODO: keep ajv extension here diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index adf48ced2e460..96d76b7e32260 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -488,17 +488,43 @@ const ChatUpdateSchema = { }, content: { type: 'object', - properties: { - algorithm: { - type: 'string', - enum: ['rc.v1.aes-sha2'], - }, - ciphertext: { - type: 'string', - }, + discriminator: { + propertyName: 'algorithm', }, required: ['algorithm', 'ciphertext'], - additionalProperties: false, + oneOf: [ + { + type: 'object', + properties: { + algorithm: { + const: 'rc.v1.aes-sha2', + }, + ciphertext: { + type: 'string', + }, + }, + additionalProperties: false, + }, + { + type: 'object', + properties: { + algorithm: { + const: 'rc.v2.aes-sha2', + }, + ciphertext: { + type: 'string', + }, + iv: { + type: 'string', + }, + kid: { + type: 'string', + } + }, + required: ['iv', 'kid'], + additionalProperties: false, + } + ] }, e2eMentions: { type: 'object', From 5929d5387b705613dc6977f3ad7a84ca545167c5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Sat, 11 Oct 2025 16:07:45 -0300 Subject: [PATCH 202/251] fix: lint errors --- packages/rest-typings/src/v1/chat.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 96d76b7e32260..546a35200b090 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -491,7 +491,6 @@ const ChatUpdateSchema = { discriminator: { propertyName: 'algorithm', }, - required: ['algorithm', 'ciphertext'], oneOf: [ { type: 'object', @@ -501,8 +500,10 @@ const ChatUpdateSchema = { }, ciphertext: { type: 'string', + minLength: 1, }, }, + required: ['algorithm', 'ciphertext'], additionalProperties: false, }, { @@ -513,18 +514,21 @@ const ChatUpdateSchema = { }, ciphertext: { type: 'string', + minLength: 1, }, iv: { type: 'string', + minLength: 1, }, kid: { type: 'string', - } + minLength: 1, + }, }, - required: ['iv', 'kid'], + required: ['algorithm', 'ciphertext', 'iv', 'kid'], additionalProperties: false, - } - ] + }, + ], }, e2eMentions: { type: 'object', From 20bad63cfc54f9e3e5d2bc2fc1b2f95c755663d5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 13 Oct 2025 12:30:58 -0300 Subject: [PATCH 203/251] fix: throw error on Binary.toArrayBuffer if character is multi-byte --- apps/meteor/client/lib/e2ee/binary.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/binary.ts b/apps/meteor/client/lib/e2ee/binary.ts index 4e1df817d06a4..fd3845ec9983b 100644 --- a/apps/meteor/client/lib/e2ee/binary.ts +++ b/apps/meteor/client/lib/e2ee/binary.ts @@ -18,7 +18,11 @@ export const Binary = { // Iterate through the string, getting the character code for each // character and setting it as the value for the corresponding byte. for (let i = 0; i < str.length; i++) { - uint8[i] = str.charCodeAt(i); + const charCode = str.charCodeAt(i); + if (charCode > 0xff) { + throw new RangeError(`illegal char code: ${charCode}`); + } + uint8[i] = charCode; } return buffer; From 72f6909faff1e42a62a247dc1c9a32de2d4e6f7f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 204/251] feat(e2ee): Refactor PBKDF2 utilities into granular functions --- apps/meteor/client/lib/e2ee/pbkdf2.ts | 116 ++++++++++++++++---------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index c870a3cbdb929..3e3f5384dad4d 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -1,45 +1,77 @@ -import { Binary } from './binary'; - -export type Pbkdf2Options = { - salt: string; +export type Options = { + salt: Uint8Array; iterations: number; }; -export type Pbkdf2 = { - decrypt: (iv: Uint8Array, data: Uint8Array) => Promise; - encrypt: (data: string) => Promise<{ iv: Uint8Array; ciphertext: ArrayBuffer }>; -}; - -export function getMasterKey(password: string, { salt, iterations }: Pbkdf2Options): Pbkdf2 { - const encodedPassword = Binary.toArrayBuffer(password); - const encodedSalt = Binary.toArrayBuffer(salt); - - const deriveKey = async (mode: 'CBC' | 'GCM') => { - const derivedKey = await crypto.subtle.deriveKey( - { name: 'PBKDF2', hash: 'SHA-256', salt: encodedSalt, iterations } satisfies Pbkdf2Params, - await crypto.subtle.importKey('raw', encodedPassword, { name: 'PBKDF2' }, false, ['deriveKey']), - { name: `AES-${mode}`, length: 256 } satisfies AesKeyGenParams, - true, - ['encrypt', 'decrypt'], - ); - return derivedKey; - }; - - return { - decrypt: async (iv, data) => { - if (iv.length !== 12 && iv.length !== 16) { - throw new Error('Invalid IV length. Must be 12 (for AES-GCM) or 16 (for AES-CBC) bytes.'); - } - const key = await deriveKey(iv.length === 16 ? 'CBC' : 'GCM'); - const decrypted = await crypto.subtle.decrypt({ name: key.algorithm.name, iv }, key, data); - return Binary.toString(decrypted); - }, - encrypt: async (data) => { - // Always use AES-GCM for new data - const key = await deriveKey('GCM'); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, Binary.toArrayBuffer(data)); - return { iv, ciphertext }; - }, - }; -} +export type EncryptedContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +}; + +export type BaseKey = CryptoKey & { algorithm: { name: 'PBKDF2' }; extractable: false; type: 'secret'; usages: ['deriveBits'] }; + +export const importBaseKey = async (keyData: Uint8Array): Promise => { + const baseKey = await crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, ['deriveBits']); + return baseKey as BaseKey; +}; + +type Narrow = { + [P in keyof T]: P extends keyof U ? U[P] : T[P]; +}; + +type FixedSizeArrayBuffer = Narrow< + ArrayBuffer, + { + // transferToFixedLength: never; + // resize: never; + // slice: never; + // transfer: never; + readonly byteLength: N; + get maxByteLength(): N; + get resizable(): false; + get detached(): false; + } +>; + +export type DerivedBits = FixedSizeArrayBuffer<32>; + +export const deriveBits = async (key: BaseKey, options: Options): Promise => { + const bits = await crypto.subtle.deriveBits( + { name: key.algorithm.name, hash: 'SHA-256', salt: options.salt, iterations: options.iterations }, + key, + 256, + ); + return bits as DerivedBits; +}; + +export type DerivedKey = Narrow< + CryptoKey, + { + readonly algorithm: Narrow; + readonly extractable: false; + readonly type: 'secret'; + readonly usages: ['decrypt'] | ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; + } +>; + +export const importKey = async (derivedBits: DerivedBits, algorithm: 'AES-CBC' | 'AES-GCM'): Promise => { + const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; + const key = await crypto.subtle.importKey('raw', derivedBits, { name: algorithm, length: 256 } satisfies AesKeyGenParams, false, usages); + return key as DerivedKey; +}; + +export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise> => { + const decrypted = await crypto.subtle.decrypt( + { name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams, + key, + content.ciphertext, + ); + return new Uint8Array(decrypted); +}; + +export const encrypt = async (key: DerivedKey, data: Uint8Array): Promise => { + // Always use AES-GCM for new data + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv } satisfies AesGcmParams, key, data); + return { iv, ciphertext: new Uint8Array(ciphertext) }; +}; From 633e0cba6cd1de595217b5db7528709d5f21442d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 205/251] test(e2ee): Add unit tests for PBKDF2 utility functions --- apps/meteor/client/lib/e2ee/pbkdf2.spec.ts | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 apps/meteor/client/lib/e2ee/pbkdf2.spec.ts diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts b/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts new file mode 100644 index 0000000000000..c5fadc1ff7acf --- /dev/null +++ b/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts @@ -0,0 +1,51 @@ +import { webcrypto } from 'node:crypto'; + +import { importBaseKey, deriveBits, importKey } from './pbkdf2'; + +Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); + +describe('pbkdf2', () => { + it('should import a base key', async () => { + const keyData = new Uint8Array([1, 2, 3, 4, 5]); + const baseKey = await importBaseKey(keyData); + expect(baseKey).toBeDefined(); + expect(baseKey.algorithm).toEqual({ name: 'PBKDF2' }); + expect(baseKey.extractable).toBe(false); + expect(baseKey.type).toBe('secret'); + expect(baseKey.usages).toEqual(['deriveBits']); + }); + + it('should derive bits from a base key', async () => { + const keyData = new Uint8Array([1, 2, 3, 4, 5]); + const salt = new Uint8Array([5, 4, 3, 2, 1]); + const iterations = 1000; + const baseKey = await importBaseKey(keyData); + const derivedBits = await deriveBits(baseKey, { salt, iterations }); + expect(derivedBits).toBeDefined(); + expect(derivedBits.byteLength).toBe(32); + }); + + it('should import a derived key for AES-CBC', async () => { + const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); + const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const algorithm = 'AES-CBC'; + const derivedKey = await importKey(derivedBits, algorithm); + expect(derivedKey).toBeDefined(); + expect(derivedKey.algorithm).toEqual({ name: 'AES-CBC', length: 256 }); + expect(derivedKey.extractable).toBe(false); + expect(derivedKey.type).toBe('secret'); + expect(derivedKey.usages).toEqual(['decrypt']); + }); + + it('should import a derived key for AES-GCM', async () => { + const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); + const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const algorithm = 'AES-GCM'; + const derivedKey = await importKey(derivedBits, algorithm); + expect(derivedKey).toBeDefined(); + expect(derivedKey.algorithm).toEqual({ name: 'AES-GCM', length: 256 }); + expect(derivedKey.extractable).toBe(false); + expect(derivedKey.type).toBe('secret'); + expect(derivedKey.usages).toEqual(['encrypt', 'decrypt']); + }); +}); From 40820a4722b3e140182d30eb00e92670ea34d3bb Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 206/251] feat(e2ee): Introduce `Codec` interface and `PrefixedBase64` codec --- apps/meteor/client/lib/e2ee/codec.ts | 83 +++++++++++++++------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index 6b3b6a86baa93..8fb0d4bbf500d 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,45 +1,54 @@ import { Base64 } from '@rocket.chat/base64'; +export type Codec = { + decode: (data: TIn) => TOut; + encode: (data: TOut) => TIn; +}; + // A 256-byte array always encodes to 344 characters in Base64. const DECODED_LENGTH = 256; // ((4 * 256 / 3) + 3) & ~3 = 344 const ENCODED_LENGTH = 344; -export const decodePrefixedBase64 = (input: string): [prefix: string, data: Uint8Array] => { - // 1. Validate the input string length - if (input.length < ENCODED_LENGTH) { - throw new RangeError('Invalid input length.'); - } - - // 2. Split the string into its two parts - const prefix = input.slice(0, -ENCODED_LENGTH); - const base64Data = input.slice(-ENCODED_LENGTH); - - // 3. Decode the Base64 string. atob() decodes to a "binary string". - const bytes = Base64.decode(base64Data); - - if (bytes.length !== DECODED_LENGTH) { - // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. - throw new RangeError('Decoded data length is too short.'); - } - - return [prefix, bytes]; -}; - -export const encodePrefixedBase64 = (prefix: string, data: Uint8Array): string => { - // 1. Validate the input data length - if (data.length !== DECODED_LENGTH) { - throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); - } - - // 2. Convert the byte array (Uint8Array) into a "binary string" - const base64Data = Base64.encode(data); - - if (base64Data.length !== ENCODED_LENGTH) { - // This is a sanity check in case something went wrong during encoding. - throw new RangeError(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); - } - - // 4. Concatenate the prefix and the Base64 string - return prefix + base64Data; +export type PrefixedBase64 = [prefix: string, data: Uint8Array]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PrefixedBase64: Codec = { + decode: (input) => { + // 1. Validate the input string length + if (input.length < ENCODED_LENGTH) { + throw new RangeError('Invalid input length.'); + } + + // 2. Split the string into its two parts + const prefix = input.slice(0, -ENCODED_LENGTH); + const base64Data = input.slice(-ENCODED_LENGTH); + + // 3. Decode the Base64 string. atob() decodes to a "binary string". + const bytes = Base64.decode(base64Data); + + if (bytes.length !== DECODED_LENGTH) { + // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. + throw new RangeError('Decoded data length is too short.'); + } + + return [prefix, bytes]; + }, + encode: ([prefix, data]) => { + // 1. Validate the input data length + if (data.length !== DECODED_LENGTH) { + throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); + } + + // 2. Convert the byte array (Uint8Array) into a "binary string" + const base64Data = Base64.encode(data); + + if (base64Data.length !== ENCODED_LENGTH) { + // This is a sanity check in case something went wrong during encoding. + throw new RangeError(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); + } + + // 4. Concatenate the prefix and the Base64 string + return prefix + base64Data; + }, }; From d6c467bae29ede7faee1423d52bf7e156eb609d8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 207/251] feat(e2ee): Implement `Keychain` for E2EE private key management --- apps/meteor/client/lib/e2ee/keychain.ts | 156 ++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/meteor/client/lib/e2ee/keychain.ts diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts new file mode 100644 index 0000000000000..e3f2024cb5d55 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -0,0 +1,156 @@ +import { Base64 } from '@rocket.chat/base64'; + +import { Binary } from './binary'; +import type { Codec } from './codec'; +import * as Pbkdf2 from './pbkdf2'; + +const generateSalt = (userId: string): string => `v2:${userId}:${crypto.randomUUID()}`; + +/** + * Version 1 format: + * ``` + * { $binary: base64(iv[16] + ciphertext) } + * ``` + */ +interface IStoredKeyV1 { + $binary: string; +} + +/** + * Version 2 format: + * ``` + * { iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() } + * ``` + */ +interface IStoredKeyV2 { + iv: string; + ciphertext: string; + salt: string; + iterations: number; +} + +type StoredKey = IStoredKeyV1 | IStoredKeyV2; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +const StoredKey: Codec = { + decode: (data) => { + const json: unknown = JSON.parse(data); + + if (typeof json !== 'object' || json === null) { + throw new TypeError('Invalid private key format'); + } + + if ('$binary' in json && typeof json.$binary === 'string') { + return { $binary: json.$binary } satisfies IStoredKeyV1; + } + + if ( + 'iv' in json && + typeof json.iv === 'string' && + 'ciphertext' in json && + typeof json.ciphertext === 'string' && + 'salt' in json && + typeof json.salt === 'string' && + 'iterations' in json && + typeof json.iterations === 'number' + ) { + return { iv: json.iv, ciphertext: json.ciphertext, salt: json.salt, iterations: json.iterations } satisfies IStoredKeyV2; + } + + throw new TypeError('Invalid private key format'); + }, + encode: (data) => JSON.stringify(data), +}; + +type EncryptedKey = { + content: { + iv: Uint8Array; + ciphertext: Uint8Array; + }; + options: { + salt: string; + iterations: number; + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +const EncryptedKey: Codec = { + encode: (encryptedKey) => { + return { + iv: Base64.encode(encryptedKey.content.iv), + ciphertext: Base64.encode(encryptedKey.content.ciphertext), + salt: encryptedKey.options.salt, + iterations: encryptedKey.options.iterations, + }; + }, + decode: (storedKey) => { + if ('$binary' in storedKey) { + // v1 + const binary = Base64.decode(storedKey.$binary); + const iv = binary.slice(0, 16); + const ciphertext = binary.slice(16); + + return { + content: { iv, ciphertext }, + options: { + salt: '', + iterations: 1000, + }, + }; + } + + // v2 + const { iv, ciphertext, salt, iterations } = storedKey; + return { + content: { + iv: Base64.decode(iv), + ciphertext: Base64.decode(ciphertext), + }, + options: { + salt, + iterations, + }, + }; + }, +}; + +export class Keychain { + userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + async decryptKey(privateKey: string, password: string): Promise { + const storedKey = StoredKey.decode(privateKey); + const { content, options } = EncryptedKey.decode(storedKey); + options.salt = options.salt ? options.salt : this.userId; + const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM'; + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); + const derivedBits = await Pbkdf2.deriveBits(baseKey, { + salt: new Uint8Array(Binary.toArrayBuffer(options.salt)), + iterations: options.iterations, + }); + const key = await Pbkdf2.importKey(derivedBits, algorithm); + const decrypted = await Pbkdf2.decrypt(key, content); + return Binary.toString(decrypted.buffer); + } + + async encryptKey(privateKey: string, password: string): Promise { + const salt = generateSalt(this.userId); + const iterations = 100_000; + const algorithm = 'AES-GCM'; + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); + const derivedBits = await Pbkdf2.deriveBits(baseKey, { salt: new Uint8Array(Binary.toArrayBuffer(salt)), iterations }); + const key = await Pbkdf2.importKey(derivedBits, algorithm); + const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.toArrayBuffer(privateKey))); + + return { + content, + options: { + salt, + iterations, + }, + }; + } +} From 78799dbfee1b298a34106b80604cacbad255098a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 208/251] test(e2ee): Add unit tests for Keychain class --- apps/meteor/client/lib/e2ee/keychain.spec.ts | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/meteor/client/lib/e2ee/keychain.spec.ts diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts new file mode 100644 index 0000000000000..d8630fb3ff4fa --- /dev/null +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -0,0 +1,49 @@ +import { webcrypto } from 'node:crypto'; + +import { Keychain } from './keychain'; + +Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); + +const pwdv1web = { + userId: 'userE2EE', + persisted: + '{"$binary":"JtJhD4JHyNS5dnf5+qj1IxbEGa6bmxlgvX6r0+LhvbnD15KmyTN/HBi0qip4GYJaolVhk7Y5YuB5qT/cRxKv6+2Qe+4Rc6DorInx1+V2tdI2JWkQoVi91GpN7ezy8ghOJ5nbsPn4xkv8jVZU2itAIAuqurKvL1hPUMsGsSnxazC6zEsInv0R/9Y2sdtm5L6ISSjo6iIqUaUEPw0gqt6y54ZSKOdTtI7xlpzJXrNdirrERiXDBj8mbzH2JG9OqfXHZWxm+NtaYLnDuwhN8M9TFj72blBU4c6z4TSb2Gc5aoAkQ9t0tlGZXbjxoa0Y0OT5UqQYVWuqDHPNyzl9ZBz6ZQ735laQZofp5roaH1g2tD9AuBO6iTvbuhwuBGdsbFj79FE5dXXKfcH44enNqL0EYGB81z1+4wuWjhyjnEuC9KXJQNqiCmftO0JQzzEqrrnYpOcFItsJPJ2TNbP2yUuITJzewjkqq4k2tpra/vblNpOnU1SkfT4PzPRk4oarUOQ9w6x8eHpXGXkck22sdPmAKNJTv7YjLSL2yLdGWKV8uxFLDHfagMPQf8e9yK3BknsJfYAU2g3PvwZIBQcrEGPkG75RvTbzFVyjPZPjDcNjvM5X1LiRTEtP9ygkGOhO+Gm+I44l9B/9Lu/mWEtonKhq5xl/aF02vutON1NKVDfmNa2mQ7HwxysKsjRAIyMBfwmVC+ave5V153iQH/3zGSx1HWaNps4IMS6/G7PsOFExTvN8ebSrQBbAdMRWoroOorW8pCIacIII5r42gCJm0ph2hcbQOL8GHbwVhc0pHhuoO7O3575YO4AsmsFueurqqqcVQY9Hbj6L5rL92nU5gphHEhb9kRJN70j6rv0mdlgXOvrXnZ4ZjIiTY+WS3kN9ecKOjVy/E7k9I3BJvPw3E6xt4cEWZsvLOdRD39ENNmUjGdRZHlU6TLleOx/cpzAhCnacyow/mG9Oijumhr8JY9PnnHJiTLBjxbtGbVoOTc4BFqYloZr2i22Luvi7p6Qf3XHtkMo02u3aaYL8u2GniIo4k3swsJzQfuYzC5SJ61i7+tzq9t/LxB1t8JDVUlRhMI20KGPauz/JJOo5ss8gkDi2y/egV3aV1/LUTzg3NWL9sXNtBaVvc2RrAOVn4pjW4BWoy0rKbT2ELfm9jpi0deUt30YwJO4g/zqutWehjj0htJ3Bbha1nzN3JZmRDONawDhao3QzYxwHHmc2VoHtUYWr150FTV7DCYVZ9uhmSxaCBvihe3Q8YSzLi+YS5EF3CgXmAUoE+hKsQk0C0drvO6nmxJTiIplxpOiscNSY0TL6crTzRGCmSLnvHOa/mcnm+8XBB/k1I1tCKjntQsjJMwlZVKP1rhvFNyIJrrnBTRZdrax9O2oBvmSH4drfqgLDmKh+mkO+9NQt3pCTCLZGWFvB3jdk27gSVNC/12uFfZof84uzlPWLN5zyzFZzIn6M5goAL+O910Jk2u7scNSHXejFC0LtYQepFMvpzKF9BY2i1We8hj3f8iDxamQpgA7/Ohl73tJfLZ1ByLlfdCLSdbO2gBYYFxOtt/GzAxfrNMa8nOKV5Vb3FBdA4IkfhQ2QrDKkJL+aBcssOmqNwUcL3C2VA11EWRenKEpwY/daxh8Kfw6nBnV6SWa/HnwKHxoNOYJVYmagh++Y/7ZYkv2uRKBP+DtVa697Yft5nYr82YjlrQA7nBGR5SUtsD/uIjDxRpMrXuFnGg3hsPo1HvTLFFUWeJH50JmAuDCA9tYmOrUVMkP+9g/lprkeHMaIsdrAXMvADeHtSIZzqqvIWUIuyLMbiMJalmsjhObbwo8gt9MNEosQT8dsExxQ4rkf2LhmOtYU7EH6wAwjPWgGUlJ/VDzmhxXQTfdO2yPOV5FfkvqAm3xJsqnf2INuPfwrOn4RWrw2qR36rNfcOGwRI8sloUpV54gkh7pszWZCxYqsbZsnnqeUREIJBqsMxP0tx7cRzC3Rrjc7+9Y5gmiRdPjHIZbTFie/vx8kR9X77y+p3Dzirq9qjACAW+08Z5MdQ2UVxL2lg6niGuq9wIa/TnMVbMPXY76Og8waBjZf6GcI+1DATPRdm9aQLK4Bb/divsOzzW1jyw7toQSfgk0cDpYqIetuixQf5+7f3WX3GXTistduUkSihA6+9HpkEnCykT4uGq1Ek+Tm1K0IkH8pOYc/kwqLqgl5AwrhoAsYwSVOgT2UxM+/pbBpRo9MfJv64OjuBXSKchv+NMcyssMZB674jdGceA=="}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}', + passphrase: 'minus mobile dexter forest elvis', +}; + +test('decrypt v1 private key', async () => { + const keychain = new Keychain(pwdv1web.userId); + const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase); + expect(decrypted).toBe(pwdv1web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase); + expect(encrypted.content.iv).toHaveLength(12); +}); + +const pwdv2web: typeof pwdv1web = { + userId: 'BFqA4FAHKX4qEywfJ', + persisted: + '{"iv":"vO0oF7jr9jGCuBqJ","ciphertext":"b6e8YDZWn+aHUTJOvs7HSPGv9TvXB/s5Dc8fVzJYGdqaoSDKC5s6cIi6TNeSPblJXYQ2dkdLjgv/VzF6q6/E3/y5px90uHzYXDw92QGwjAHW9jsekSKoxnszMD64iB8O2RCFQPepmx15Upuo8NdnyGno+sbp9G4DkKcB2XrAw53V9BVpJixGc+MNWuRe+TyW49CkDL9kdukPPFYzi5p0qtgUoQ1IiqTUYWU1vVbYia76xEKuHrG+xjdXpXeLLu6FewO1xCWUli32ydQHoyty2WDV3ITfddUPIiPSNjspJXnp7cBKuabap4WuwLWfonv25PjZWICYF4l10Q1gESOmR7sOUBrKVBTQvZfPpZmSHhfMfqeDzuODyNTDAvBHavlIcR11xzIHiy/dofTUL8O+ctiQC+P0i3cN7/hrDXfb+mJHtrKGEO9beSbpYsar4nwaWI4irwFgbg6Ltd2spf7W00KFoQEhTfpZum86wcgT0vxaQRbeH5zHvWgPH8aWvc4NmXV/Tcnf9P5rd/mbSl8F6jk2O7ygkZdkDfmUAxOQ9tjfz16KV9zm+Tlx5V276/a2wfiZ73LpNwWA6DS8+3ncN2nhFsjn0Ma+I4gyrRm1jnr7NUsLg+E+A7cEaN60JEf5IOcFmJ94emh945qv3gpQyUYsCnNR/wBSc3bgr/qC9vK3jKObfYKygt5KaMoLE9YBXqyaZ5Sht63d6sIq8GJis01eUx3+90QyOYKulL0nCiSLREZyyv5LPDbVrcJIV63CuRibbVtCyrQAKRqxm8lyw3gCKNRgNDxTWMnPJd4BN4orq1rI1PDM2zADoSjQEEkf9wo/qaHey1sNufOT8pDu/J9+hKlrwyVFc+x/ApGBxQjLK6NPKDBCJvuqwDu17EHxRlfber/KyyJjK/ymo0xLsXgvjxci4tCrtnaxuqeLXP8W5Bm7akvRKjJj1mY9pztP6vIdgwSRNnPQMRrz36s8czd0e0ba/CpUnWfTUfVE3/MgmWry11KmnyAPDs/jekgmKRKVzkyxQxl+vPjgA3rAlEof2UMqmMaIFQOEbIdFRiy9PddPAVDwDZUHpPo8bFWa22P8g9f0F8d9rLwxUoWfIRB2jqwBea553lvwjt+HXYx1sAUqG2cXagXypi4nT2dRrooDoyeZxrOmtEX4oAIs9yeeQzPRdUlJMpYLnhAv9cjuouEN6RXUuyILUb6fTn2EbE/B147SojFwCMUfp4IC6cPjpwi/O7v0cC9OjcE9/YzWYTUGq2SC5CYZ6TQ9SVwmidcAq8lD4O0gLeYd1lKis3W42q9mIScxKf9l49g6v94kecvoWQ2p5WiUItwnHoTGNBZHH0h9fQbDVorZ7gS6/ZdhoDWUHOjDwN/yP8y3BV2OBGgw0RXhvM13W+G9g/Zj4aQ44T0alCiA34miELY5paznFdjzkk7wo7RIVRllESLf1xvbPRHDlGG5RYs1z1ueGBVGtkC826yTsN6nFdaxRd2Ntvgmbmpn3AiEoLxrAK4YbJ23X30W6iz5gen8j9O2N79vE+jvTcgBtP/38J0Lyy0OPQfH/VbrcEzrMdsPbyU87tORrhsUFg+NX1H0aVfTgLRM0h1kCYAtr2HpfIo4QCRHT8ar12aOKb69q8gNCdMf74S0krKIxiRAB8VgVMc4xCJQzjizOsosEj+sI6hMtrepjecf0guoGR5+qeYZ/WfREHLK9RRRzOXQntKdREt7dMJtYYlRZ50hT++tWoA6w4S0AyqCV7Q37e/YQtadkOkqwPo0F/TE2vuqmeRa14ZiZRh7ilMWv2cZ4AUGAWEtPkVs8lWlS5WvuRz0Jr3Qy5QnllPzSkzCotPA1+Ecap05SCnAgwG/RUt3UaUIU7+FDL/UHcmMX0AK4ec1MG98C6s31fmCgSTilEsKOj9zCMCGYfmp+MnX9YSqqrFdA8E2OLjscOkh17/FliGzx16QcAglTbU51DDhU8e0n/Kc6AHkylvmydfEm/PP23jGCO8hjaafWLcK6NRNxmK2EsHIut6sb0eUHwDBGYTdJPXvN961j41OIG/Piy0qj+329V9wkrjxGqklcHCVYHCyJX4/PLo3ieBPb4gf2xApFuaxGjsnufe1QY9jitSo8SPGBmcFfZOWdNgtEHK3HfrrSH30H4aMI9wRcbni36RjeRo9LjRgcPFeTljLo44323EjWk1UJ4P+h+zz5LLjXEFDKLPUyFe+AA/blisN1XRbwpvmAWBPVrZuZ4s=","salt":"v2:BFqA4FAHKX4qEywfJ:5b36c4cc-4e7a-497d-b4b0-a1df863c65c4","iterations":100000}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"BfaLapcFxwaKxDjeSCR4wb6H5xF5QCw36QvqX8JvqUX_NA4xFtn6RqgdVhMXep3DW641cA1xb8oIxRGt2YL1kb0gN97fdINtOMXSumebZODtYf5EZ-inJtIdDXcy2MjsSMZIqUwuQ5gRq90SxSpKE9eRrEfuuh5nxF5BWwNp65ZW4rCsEiInmnrFW4ERtyTHJ1catbJ_lj71lcVzC-1St6jeXcdinG2FtH0hJ4_ijzp6sAqm1xC9XMhF2g4tZeyvVg9dGBzyLyxq78zNAPJ93ungjc0ITJ27g3IaP7EUX7SxjHasU37j7KOIOGmTswkxIEoVQrlep6xU1RFkPphJNQ","dp":"sJhWI8YfVUr1N5vTr255xJ3Bo84320NWAl9MUhd87XoV3soGO0lmC1bYdrNvIU7wjJ3PxdpSrJ2HQDY5dR088RcmD4J1i3PFJUXVW0A_YkTIt2k8x5m1yF6npS0AgxgwauFxGcE7KgO2vtBn5SHMUOB-gmgUYhDRVB8NdQ3Z550","dq":"jXuFkyialQd264s-RW5adF637uYgh1pnutcQ1wI8HzVAcr3L36xrjQxDYMY6n5uNJJE0I3LyIe9Ez4j_83wiV3nsjFhSj-i7Doiy2k2zHRBqS9ajg933KVZVWD6fN7nr31jF_cdbkzzldkpgxXi3EeM2_0TN7kt1s1kvSeuEoME","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ","p":"4yd_HHhnSAh-eGnF31n0ihyr2M7UBc3m2IDxbQBTohi3qUGD1x5YRlPtx1zpr0vZgoTkDUZ6DPbdXp6QBRku6SOKDrHA0jFiVdqmmVEPGKa1fLVtRIl_kDI-02-F4lvYlTfIQ4qchT64VSvwJnnxUlsuodfbohZi4zYaIMvzXZs","q":"xYTnIC-3Cy31zs0ZC5YTCTYp9KWD__jtcZjBmoqpc_dyhPa-Xzz2ty-6ytP_23mGi2vOB4rZjE9v2gh18l4AzhIrqR9ns4pxYZG4S2O3Si8oHavMJE4mx7VN6Lmgqs3DD45tA7zHbj8hnBg1It2aftWkvzxGx2yQe_r0JomXA7M","qi":"JF4tOm05ianqiNk2jtArg9O4kL8ZjiNgBA97Yu05b_f7k4MMKZqmRihCvzSMpk6w4pqkIdmnSSw19feNCXDv4QHFv4eSsTvBD8g_zmwIkMFyJzeC34J5bgafteZ1RpXsxN5CAAjyhJObEll3CY9B9zZTCLuZDr_616RWDvK1_w4"}', + passphrase: 'prize fluid crystal small jaguar lunar bonus absent destroy settle carbon ignore', +}; + +test('decrypt v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); +}); + +test('roundtrip v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase); + expect(encrypted.content.iv).toHaveLength(12); +}); From feed8f0617dd30395de99a2a51869456486df957 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 209/251] refactor(e2ee): Integrate `PrefixedBase64` codec into `E2ERoom` --- .../client/lib/e2ee/rocketchat.e2e.room.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 11b2ca852cfa1..fba05a51e6286 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -16,10 +16,10 @@ import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; import * as Aes from './aes'; -import { decodePrefixedBase64, encodePrefixedBase64 } from './codec'; +import { Binary } from './binary'; +import { PrefixedBase64 } from './codec'; import { decodeEncryptedContent } from './content'; import { - toString, toArrayBuffer, readFileAsArrayBuffer, encryptAESCTR, @@ -365,7 +365,7 @@ export class E2ERoom extends Emitter { async exportSessionKey(key: string) { const span = log.span('exportSessionKey'); - const [prefix, decodedKey] = decodePrefixedBase64(key); + const [prefix, decodedKey] = PrefixedBase64.decode(key); span.set('prefix', prefix); @@ -376,12 +376,12 @@ export class E2ERoom extends Emitter { const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); span.info('Key decrypted'); - return toString(decryptedKey); + return Binary.toString(decryptedKey.buffer); } async importGroupKey(groupKey: string) { const span = log.span('importGroupKey'); - const [kid, decodedKey] = decodePrefixedBase64(groupKey); + const [kid, decodedKey] = PrefixedBase64.decode(groupKey); span.set('kid', kid); @@ -396,7 +396,7 @@ export class E2ERoom extends Emitter { throw new Error('Private key not found'); } const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); - this.sessionKeyExportedString = toString(decryptedKey); + this.sessionKeyExportedString = Binary.toString(decryptedKey.buffer); } catch (error) { span.set('error', error).error('Error decrypting group key'); return false; @@ -549,8 +549,8 @@ export class E2ERoom extends Emitter { if (!oldRoomKey.E2EKey) { continue; } - const encryptedKey = await Rsa.encrypt(userKey, toArrayBuffer(oldRoomKey.E2EKey)); - const encryptedKeyToString = encodePrefixedBase64(oldRoomKey.e2eKeyId, encryptedKey); + const encryptedKey = await Rsa.encrypt(userKey, Binary.toArrayBuffer(oldRoomKey.E2EKey)); + const encryptedKeyToString = PrefixedBase64.encode([oldRoomKey.e2eKeyId, encryptedKey]); keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); } @@ -571,8 +571,8 @@ export class E2ERoom extends Emitter { // Encrypt session key for this user with his/her public key try { - const encryptedUserKey = await Rsa.encrypt(userKey, toArrayBuffer(this.sessionKeyExportedString)); - const encryptedUserKeyToString = encodePrefixedBase64(this.keyID, encryptedUserKey); + const encryptedUserKey = await Rsa.encrypt(userKey, Binary.toArrayBuffer(this.sessionKeyExportedString!)); + const encryptedUserKeyToString = PrefixedBase64.encode([this.keyID, encryptedUserKey]); span.info('Group key encrypted for participant'); return encryptedUserKeyToString; } catch (error) { From 6d3134d27b64e3f8515615551e7f4e384229b8c7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 210/251] refactor(e2ee): Integrate `Keychain` into `E2E` class --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 67 +++---------------- 1 file changed, 8 insertions(+), 59 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index d65a27f02873b..b99cbe90dea74 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,7 +1,6 @@ import QueryString from 'querystring'; import URL from 'url'; -import { Base64 } from '@rocket.chat/base64'; import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; @@ -12,8 +11,8 @@ import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; import { generatePassphrase } from './helper'; +import { Keychain } from './keychain'; import { createLogger } from './logger'; -import { getMasterKey, type Pbkdf2Options } from './pbkdf2'; import { E2ERoom } from './rocketchat.e2e.room'; import * as Rsa from './rsa'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; @@ -283,7 +282,9 @@ class E2E extends Emitter { throw new Error('Failed to persist keys as they are not strings.'); } - const encodedPrivateKey = await this.encodePrivateKey(private_key, password); + const keychain = new Keychain(this.getUserId()); + + const encodedPrivateKey = await keychain.encryptKey(private_key, password); if (!encodedPrivateKey) { throw new Error('Failed to encode private key with provided password.'); @@ -510,25 +511,6 @@ class E2E extends Emitter { return randomPassword; } - async encodePrivateKey(privateKey: string, password: string): Promise<{ iv: string; ciphertext: string } & Pbkdf2Options> { - const { userId } = this; - if (!userId) { - throw new Error('No userId found'); - } - - const salt = `v2:${userId}:${crypto.randomUUID()}`; - const iterations = 100_000; - - const result = await getMasterKey(password, { salt, iterations }).encrypt(privateKey); - - return { - iv: Base64.encode(result.iv), - ciphertext: Base64.encode(new Uint8Array(result.ciphertext)), - salt, - iterations, - }; - } - openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) { imperativeModal.open({ component: EnterE2EPasswordModal, @@ -585,11 +567,10 @@ class E2E extends Emitter { return; } - const { iv, ciphertext, salt, iterations } = this.parsePrivateKey(this.db_private_key); - const masterKey = getMasterKey(password, { salt, iterations }); + const keychain = new Keychain(this.getUserId()); try { - const privateKey = await masterKey.decrypt(iv, ciphertext); + const privateKey = await keychain.decryptKey(this.db_private_key, password); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); @@ -607,44 +588,12 @@ class E2E extends Emitter { } } - parsePrivateKey(privateKey: string): Pbkdf2Options & { iv: Uint8Array; ciphertext: Uint8Array } { - const json: unknown = JSON.parse(privateKey); - const userId = this.getUserId(); - - if (typeof json !== 'object' || json === null) { - throw new TypeError('Invalid private key format'); - } - - if ('$binary' in json && typeof json.$binary === 'string') { - // v1: { $binary: base64(iv[16] + ciphertext) } - const binary = Base64.decode(json.$binary); - const [iv, ciphertext] = [binary.slice(0, 16), binary.slice(16)]; - return { iv, ciphertext, iterations: 1000, salt: userId }; - } - - if (!('iv' in json) || typeof json.iv !== 'string' || !('ciphertext' in json) || typeof json.ciphertext !== 'string') { - throw new TypeError('Invalid private key format'); - } - - // v2: { iv: base64, ciphertext: base64, salt: string, iterations: number } - - const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : userId; - const iterations = 'iterations' in json && typeof json.iterations === 'number' ? json.iterations : 100_000; - - return { iv: Base64.decode(json.iv), ciphertext: Base64.decode(json.ciphertext), salt, iterations }; - } - async decodePrivateKey(privateKey: string): Promise { // const span = log.span('decodePrivateKey'); const password = await this.requestPasswordAlert(); - const { iv, ciphertext, salt, iterations } = this.parsePrivateKey(privateKey); - const masterKey = getMasterKey(password, { salt, iterations }); - + const keychain = new Keychain(this.getUserId()); try { - if (!masterKey) { - throw new Error('Error getting master key'); - } - const privKey = await masterKey.decrypt(iv, ciphertext); + const privKey = await keychain.decryptKey(privateKey, password); return privKey; } catch (error) { this.setState('ENTER_PASSWORD'); From 34aa35b0f91efc4ae3a3028a00aaa18acd671c62 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Oct 2025 16:58:22 -0300 Subject: [PATCH 211/251] test(e2e): Update E2E tests and user fixtures for E2EE changes --- .../e2ee-passphrase-management.spec.ts | 36 ++++++++++++++++--- apps/meteor/tests/e2e/fixtures/userStates.ts | 2 +- apps/meteor/tests/e2e/page-objects/login.ts | 6 ++-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts index 4ce8b687e223f..77fcbc1a5ffe5 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -42,12 +42,12 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms }); }); - test.beforeEach(async ({ api, page }) => { + test.beforeEach(async ({ page }) => { const loginPage = new LoginPage(page); - await api.post('/method.call/e2e.resetOwnE2EKey', { - message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), - }); + // await api.post('/method.call/e2e.resetOwnE2EKey', { + // message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), + // }); await page.goto('/home'); await loginPage.waitForIt(); @@ -142,6 +142,34 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { // No error banner await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); }); + + test.describe('E2EE Passphrase Management - Recovery', () => { + test.use({ + storageState: { + ...Users.userE2EE.state, + origins: Users.userE2EE.state.origins.map((origin) => ({ + ...origin, + localStorage: origin.localStorage.filter((item) => !['e2e.randomPassword', 'private_key', 'public_key'].includes(item.name)), + })), + }, + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/home'); + }); + + test('expect to recover the keys using the recovery key', async ({ page }) => { + await test.step('Recover the keys', async () => { + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(Users.userE2EE.data.e2ePassword!); + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); + }); + }); }); test.use({ storageState: Users.admin.state }); diff --git a/apps/meteor/tests/e2e/fixtures/userStates.ts b/apps/meteor/tests/e2e/fixtures/userStates.ts index 91de7ac9c193c..6890955cf0a9d 100644 --- a/apps/meteor/tests/e2e/fixtures/userStates.ts +++ b/apps/meteor/tests/e2e/fixtures/userStates.ts @@ -24,7 +24,7 @@ const e2eeData: Record options.except.indexOf(item.name) === -1); + // Injects the login token to the local storage await this.page.evaluate((items) => { items.forEach(({ name, value }) => { @@ -46,7 +48,7 @@ export class LoginPage { }); // eslint-disable-next-line @typescript-eslint/no-var-requires require('meteor/accounts-base').Accounts._pollStoredLoginToken(); - }, userState.state.origins[0].localStorage); + }, localStorageItems); await this.waitForLogin(); } From 6010471f23a6f6bddd94a58ff592c822dd3db0f5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 11:07:20 -0300 Subject: [PATCH 212/251] fix(e2e): correct E2EE passphrase management test structure --- .../e2ee-passphrase-management.spec.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts index 77fcbc1a5ffe5..520dc92d10d0a 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -142,32 +142,32 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { // No error banner await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); }); +}); - test.describe('E2EE Passphrase Management - Recovery', () => { - test.use({ - storageState: { - ...Users.userE2EE.state, - origins: Users.userE2EE.state.origins.map((origin) => ({ - ...origin, - localStorage: origin.localStorage.filter((item) => !['e2e.randomPassword', 'private_key', 'public_key'].includes(item.name)), - })), - }, - }); +test.describe('E2EE Passphrase Management - Recovery', () => { + test.use({ + storageState: { + ...Users.userE2EE.state, + origins: Users.userE2EE.state.origins.map((origin) => ({ + ...origin, + localStorage: origin.localStorage.filter((item) => !['e2e.randomPassword', 'private_key', 'public_key'].includes(item.name)), + })), + }, + }); - test.beforeEach(async ({ page }) => { - await page.goto('/home'); - }); + test.beforeEach(async ({ page }) => { + await page.goto('/home'); + }); - test('expect to recover the keys using the recovery key', async ({ page }) => { - await test.step('Recover the keys', async () => { - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + test('expect to recover the keys using the recovery key', async ({ page }) => { + await test.step('Recover the keys', async () => { + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(Users.userE2EE.data.e2ePassword!); - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); - }); + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(Users.userE2EE.data.e2ePassword!); + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); }); }); }); From 96ef23978dc879b90bdb9ff3953668227f774bab Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 11:07:20 -0300 Subject: [PATCH 213/251] refactor(e2ee): enhance cryptographic key type definitions --- apps/meteor/client/lib/e2ee/pbkdf2.ts | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index 3e3f5384dad4d..757242422a7c5 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -8,24 +8,26 @@ export type EncryptedContent = { ciphertext: Uint8Array; }; -export type BaseKey = CryptoKey & { algorithm: { name: 'PBKDF2' }; extractable: false; type: 'secret'; usages: ['deriveBits'] }; +type Narrow = { + [P in keyof T]: P extends keyof U ? U[P] : T[P]; +}; + +export type BaseKey = Narrow< + CryptoKey, + { algorithm: Narrow; extractable: false; type: 'secret'; usages: ['deriveBits'] } +>; export const importBaseKey = async (keyData: Uint8Array): Promise => { const baseKey = await crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, ['deriveBits']); return baseKey as BaseKey; }; -type Narrow = { - [P in keyof T]: P extends keyof U ? U[P] : T[P]; -}; +type Throws = F extends (...args: infer TArgs) => infer TRet ? (...args: TArgs) => TRet & never : never; type FixedSizeArrayBuffer = Narrow< ArrayBuffer, { - // transferToFixedLength: never; - // resize: never; - // slice: never; - // transfer: never; + resize: Throws; readonly byteLength: N; get maxByteLength(): N; get resizable(): false; @@ -44,20 +46,22 @@ export const deriveBits = async (key: BaseKey, options: Options): Promise = Narrow< CryptoKey, { - readonly algorithm: Narrow; + readonly algorithm: Narrow; readonly extractable: false; readonly type: 'secret'; - readonly usages: ['decrypt'] | ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; + readonly usages: T extends 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; } >; -export const importKey = async (derivedBits: DerivedBits, algorithm: 'AES-CBC' | 'AES-GCM'): Promise => { +export const importKey = async (derivedBits: DerivedBits, algorithm: T): Promise> => { const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; const key = await crypto.subtle.importKey('raw', derivedBits, { name: algorithm, length: 256 } satisfies AesKeyGenParams, false, usages); - return key as DerivedKey; + return key as DerivedKey; }; export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise> => { From 08a4d28fa587e47ebf0f430f1e1368d68614bd03 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 11:07:20 -0300 Subject: [PATCH 214/251] refactor(e2ee): narrow key type for `encrypt` function --- apps/meteor/client/lib/e2ee/pbkdf2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index 757242422a7c5..14d72890cc8af 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -73,7 +73,7 @@ export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promi return new Uint8Array(decrypted); }; -export const encrypt = async (key: DerivedKey, data: Uint8Array): Promise => { +export const encrypt = async (key: DerivedKey<'AES-GCM'>, data: Uint8Array): Promise => { // Always use AES-GCM for new data const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv } satisfies AesGcmParams, key, data); From f73043406d068ea26530625ad2d09674e9f81ed4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 11:07:20 -0300 Subject: [PATCH 215/251] test(e2ee): update `pbkdf2` tests for stricter type assertions --- apps/meteor/client/lib/e2ee/pbkdf2.spec.ts | 44 +++++++++++----------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts b/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts index c5fadc1ff7acf..f832cf2b9d976 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts @@ -8,11 +8,11 @@ describe('pbkdf2', () => { it('should import a base key', async () => { const keyData = new Uint8Array([1, 2, 3, 4, 5]); const baseKey = await importBaseKey(keyData); - expect(baseKey).toBeDefined(); - expect(baseKey.algorithm).toEqual({ name: 'PBKDF2' }); - expect(baseKey.extractable).toBe(false); - expect(baseKey.type).toBe('secret'); - expect(baseKey.usages).toEqual(['deriveBits']); + expect<'PBKDF2'>(baseKey.algorithm.name).toBe('PBKDF2'); + expect(baseKey.extractable).toBe(false); + expect<'secret'>(baseKey.type).toBe('secret'); + expect<1>(baseKey.usages.length).toBe(1); + expect<'deriveBits'>(baseKey.usages[0]).toBe('deriveBits'); }); it('should derive bits from a base key', async () => { @@ -21,31 +21,33 @@ describe('pbkdf2', () => { const iterations = 1000; const baseKey = await importBaseKey(keyData); const derivedBits = await deriveBits(baseKey, { salt, iterations }); - expect(derivedBits).toBeDefined(); - expect(derivedBits.byteLength).toBe(32); + expect(derivedBits.detached).toBe(false); + expect(derivedBits.resizable).toBe(false); + expect<32>(derivedBits.byteLength).toBe(32); + expect<32>(derivedBits.maxByteLength).toBe(32); + expect<() => never>(() => derivedBits.resize(32)).toThrow(); }); it('should import a derived key for AES-CBC', async () => { const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); - const algorithm = 'AES-CBC'; - const derivedKey = await importKey(derivedBits, algorithm); - expect(derivedKey).toBeDefined(); - expect(derivedKey.algorithm).toEqual({ name: 'AES-CBC', length: 256 }); - expect(derivedKey.extractable).toBe(false); - expect(derivedKey.type).toBe('secret'); - expect(derivedKey.usages).toEqual(['decrypt']); + const derivedKey = await importKey(derivedBits, 'AES-CBC'); + expect<256>(derivedKey.algorithm.length).toBe(256); + expect<'AES-CBC'>(derivedKey.algorithm.name).toBe('AES-CBC'); + expect(derivedKey.extractable).toBe(false); + expect<'secret'>(derivedKey.type).toBe('secret'); + expect<1>(derivedKey.usages.length).toBe(1); + expect<['decrypt']>(derivedKey.usages).toEqual(['decrypt']); }); it('should import a derived key for AES-GCM', async () => { const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); - const algorithm = 'AES-GCM'; - const derivedKey = await importKey(derivedBits, algorithm); - expect(derivedKey).toBeDefined(); - expect(derivedKey.algorithm).toEqual({ name: 'AES-GCM', length: 256 }); - expect(derivedKey.extractable).toBe(false); - expect(derivedKey.type).toBe('secret'); - expect(derivedKey.usages).toEqual(['encrypt', 'decrypt']); + const derivedKey = await importKey(derivedBits, 'AES-GCM'); + expect<256>(derivedKey.algorithm.length).toBe(256); + expect<'AES-GCM'>(derivedKey.algorithm.name).toBe('AES-GCM'); + expect(derivedKey.extractable).toBe(false); + expect<'secret'>(derivedKey.type).toBe('secret'); + expect<['encrypt', 'decrypt']>(derivedKey.usages).toEqual(['encrypt', 'decrypt']); }); }); From bf8a1a8c1951ca6e14d0e621baec2aaaabe91306 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 13:19:32 -0300 Subject: [PATCH 216/251] docs(e2ee): Clarify IStoredKeyV1 binary format with JSDoc --- apps/meteor/client/lib/e2ee/keychain.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index e3f2024cb5d55..e948a96922bee 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -13,6 +13,11 @@ const generateSalt = (userId: string): string => `v2:${userId}:${crypto.randomUU * ``` */ interface IStoredKeyV1 { + /** + * Base64-encoded binary data + * - first 16 bytes are the IV + * - remaining bytes are the ciphertext + */ $binary: string; } From b3d273c2fdf76bbc6aed9bc1351bd133cf88a538 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 13:19:32 -0300 Subject: [PATCH 217/251] refactor(e2ee): Standardize encrypted key storage with EncryptedKey.encode --- apps/meteor/client/lib/e2ee/keychain.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index e948a96922bee..8c99771b31b42 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -141,7 +141,7 @@ export class Keychain { return Binary.toString(decrypted.buffer); } - async encryptKey(privateKey: string, password: string): Promise { + async encryptKey(privateKey: string, password: string): Promise { const salt = generateSalt(this.userId); const iterations = 100_000; const algorithm = 'AES-GCM'; @@ -150,12 +150,6 @@ export class Keychain { const key = await Pbkdf2.importKey(derivedBits, algorithm); const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.toArrayBuffer(privateKey))); - return { - content, - options: { - salt, - iterations, - }, - }; + return EncryptedKey.encode({ content, options: { salt, iterations } }); } } From da8f9cd83b2cad28158ac884e44da862ee5a86ab Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 13:19:32 -0300 Subject: [PATCH 218/251] chore(e2e-tests): Enable E2E key reset for passphrase management tests --- .../e2e/e2e-encryption/e2ee-passphrase-management.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts index 520dc92d10d0a..2d759fc052619 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -42,12 +42,12 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms }); }); - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page, api }) => { const loginPage = new LoginPage(page); - // await api.post('/method.call/e2e.resetOwnE2EKey', { - // message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), - // }); + await api.post('/method.call/e2e.resetOwnE2EKey', { + message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), + }); await page.goto('/home'); await loginPage.waitForIt(); From 8921927fa20c365aefa7b363c9894f409e03fd84 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:16:04 -0300 Subject: [PATCH 219/251] docs(e2ee): Clarify stored key format comments --- apps/meteor/client/lib/e2ee/keychain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index 8c99771b31b42..2fabd459ae399 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -23,8 +23,8 @@ interface IStoredKeyV1 { /** * Version 2 format: - * ``` - * { iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() } + * ```typescript + * json({ iv: base64(iv[12]), ciphertext: base64(data[...]), salt: string(), iterations: number() }) * ``` */ interface IStoredKeyV2 { From 7447423732bd816905cbb399e2f7dd6291515cd4 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:16:04 -0300 Subject: [PATCH 220/251] refactor(e2ee): Refine `Codec` type to support specific encode return types --- apps/meteor/client/lib/e2ee/codec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index 8fb0d4bbf500d..29a8cd296cd87 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,8 +1,8 @@ import { Base64 } from '@rocket.chat/base64'; -export type Codec = { +export type Codec = { decode: (data: TIn) => TOut; - encode: (data: TOut) => TIn; + encode: (data: TOut) => TEnc; }; // A 256-byte array always encodes to 344 characters in Base64. From cffa28c2a20c525b5dad470f915a626a85e37b64 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:16:04 -0300 Subject: [PATCH 221/251] refactor(e2ee): Standardize `EncryptedKey` representation and serialization --- apps/meteor/client/lib/e2ee/keychain.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index 2fabd459ae399..0da8b63593bcb 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -67,19 +67,23 @@ const StoredKey: Codec = { encode: (data) => JSON.stringify(data), }; +type EncryptedKeyContent = { + iv: Uint8Array; + ciphertext: Uint8Array; +} + +type EncryptedKeyOptions = { + salt: string; + iterations: number; +} + type EncryptedKey = { - content: { - iv: Uint8Array; - ciphertext: Uint8Array; - }; - options: { - salt: string; - iterations: number; - }; + content: EncryptedKeyContent; + options: EncryptedKeyOptions; }; // eslint-disable-next-line @typescript-eslint/no-redeclare -const EncryptedKey: Codec = { +const EncryptedKey: Codec = { encode: (encryptedKey) => { return { iv: Base64.encode(encryptedKey.content.iv), From 7c2f43d0a3a096beab5872456611eff296a1fae3 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:16:04 -0300 Subject: [PATCH 222/251] refactor(e2ee): Update keychain encryption for new `IStoredKeyV2` format and add `Serialized` type --- apps/meteor/client/lib/e2ee/keychain.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index 0da8b63593bcb..1a790628115ff 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -4,12 +4,13 @@ import { Binary } from './binary'; import type { Codec } from './codec'; import * as Pbkdf2 from './pbkdf2'; -const generateSalt = (userId: string): string => `v2:${userId}:${crypto.randomUUID()}`; +export type Serialized = T extends BufferSource ? string : { [K in keyof T]: Serialized }; + /** * Version 1 format: * ``` - * { $binary: base64(iv[16] + ciphertext) } + * json({ $binary: base64(iv[16] + ciphertext) }) * ``` */ interface IStoredKeyV1 { @@ -145,8 +146,8 @@ export class Keychain { return Binary.toString(decrypted.buffer); } - async encryptKey(privateKey: string, password: string): Promise { - const salt = generateSalt(this.userId); + async encryptKey(privateKey: string, password: string): Promise { + const salt = `v2:${this.userId}:${crypto.randomUUID()}`; const iterations = 100_000; const algorithm = 'AES-GCM'; const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); From 19b72aa18560d974b15188ee0dcefa26cbd2fc63 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:16:04 -0300 Subject: [PATCH 223/251] test(e2ee): Add `base64Length` utility and update keychain tests --- apps/meteor/client/lib/e2ee/keychain.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts index d8630fb3ff4fa..80572a95c8169 100644 --- a/apps/meteor/client/lib/e2ee/keychain.spec.ts +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -4,6 +4,16 @@ import { Keychain } from './keychain'; Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); +/** + * Calculates the length of a base64-encoded string given the length of the original byte array. + * + * @param byteLength The length of the byte array to be encoded. + * @returns The length of the resulting base64-encoded string. + */ +const base64Length = (byteLength: number) => { + return ((4 * byteLength / 3) + 3) & ~3 +} + const pwdv1web = { userId: 'userE2EE', persisted: @@ -20,7 +30,7 @@ test('decrypt v1 private key', async () => { const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase); expect(decrypted).toBe(pwdv1web.private_key); const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase); - expect(encrypted.content.iv).toHaveLength(12); + expect(encrypted.iv).toHaveLength(base64Length(12)); }); const pwdv2web: typeof pwdv1web = { @@ -45,5 +55,5 @@ test('roundtrip v2 private key', async () => { const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); expect(decrypted).toBe(pwdv2web.private_key); const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase); - expect(encrypted.content.iv).toHaveLength(12); + expect(encrypted.iv).toHaveLength(base64Length(12)); }); From 86097b0a3b0b8b8a2c7a3646e78df281aa8d33e5 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:31:19 -0300 Subject: [PATCH 224/251] chore: Apply minor formatting and whitespace consistency --- apps/meteor/client/lib/e2ee/keychain.spec.ts | 6 +++--- apps/meteor/client/lib/e2ee/keychain.ts | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts index 80572a95c8169..d98b01d670873 100644 --- a/apps/meteor/client/lib/e2ee/keychain.spec.ts +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -6,13 +6,13 @@ Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); /** * Calculates the length of a base64-encoded string given the length of the original byte array. - * + * * @param byteLength The length of the byte array to be encoded. * @returns The length of the resulting base64-encoded string. */ const base64Length = (byteLength: number) => { - return ((4 * byteLength / 3) + 3) & ~3 -} + return ((4 * byteLength) / 3 + 3) & ~3; +}; const pwdv1web = { userId: 'userE2EE', diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index 1a790628115ff..f712a76de4506 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -6,7 +6,6 @@ import * as Pbkdf2 from './pbkdf2'; export type Serialized = T extends BufferSource ? string : { [K in keyof T]: Serialized }; - /** * Version 1 format: * ``` @@ -71,12 +70,12 @@ const StoredKey: Codec = { type EncryptedKeyContent = { iv: Uint8Array; ciphertext: Uint8Array; -} +}; type EncryptedKeyOptions = { salt: string; iterations: number; -} +}; type EncryptedKey = { content: EncryptedKeyContent; From 1aba28671a4b714f3a64140394eb2ed8764817fd Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 14:31:19 -0300 Subject: [PATCH 225/251] refactor(e2ee): Mark PBKDF2 algorithm name as readonly --- apps/meteor/client/lib/e2ee/pbkdf2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/pbkdf2.ts index 14d72890cc8af..473cabdaaf3a9 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/pbkdf2.ts @@ -14,7 +14,7 @@ type Narrow = { export type BaseKey = Narrow< CryptoKey, - { algorithm: Narrow; extractable: false; type: 'secret'; usages: ['deriveBits'] } + { algorithm: Narrow; extractable: false; type: 'secret'; usages: ['deriveBits'] } >; export const importBaseKey = async (keyData: Uint8Array): Promise => { From 6c8b65ba6751c90b9390382670ccc91dc15a0245 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 16:36:42 -0300 Subject: [PATCH 226/251] chore(e2ee): group keychain tests within a describe block --- apps/meteor/client/lib/e2ee/keychain.spec.ts | 106 ++++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts index d98b01d670873..c788825eecd80 100644 --- a/apps/meteor/client/lib/e2ee/keychain.spec.ts +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -4,56 +4,58 @@ import { Keychain } from './keychain'; Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); -/** - * Calculates the length of a base64-encoded string given the length of the original byte array. - * - * @param byteLength The length of the byte array to be encoded. - * @returns The length of the resulting base64-encoded string. - */ -const base64Length = (byteLength: number) => { - return ((4 * byteLength) / 3 + 3) & ~3; -}; - -const pwdv1web = { - userId: 'userE2EE', - persisted: - '{"$binary":"JtJhD4JHyNS5dnf5+qj1IxbEGa6bmxlgvX6r0+LhvbnD15KmyTN/HBi0qip4GYJaolVhk7Y5YuB5qT/cRxKv6+2Qe+4Rc6DorInx1+V2tdI2JWkQoVi91GpN7ezy8ghOJ5nbsPn4xkv8jVZU2itAIAuqurKvL1hPUMsGsSnxazC6zEsInv0R/9Y2sdtm5L6ISSjo6iIqUaUEPw0gqt6y54ZSKOdTtI7xlpzJXrNdirrERiXDBj8mbzH2JG9OqfXHZWxm+NtaYLnDuwhN8M9TFj72blBU4c6z4TSb2Gc5aoAkQ9t0tlGZXbjxoa0Y0OT5UqQYVWuqDHPNyzl9ZBz6ZQ735laQZofp5roaH1g2tD9AuBO6iTvbuhwuBGdsbFj79FE5dXXKfcH44enNqL0EYGB81z1+4wuWjhyjnEuC9KXJQNqiCmftO0JQzzEqrrnYpOcFItsJPJ2TNbP2yUuITJzewjkqq4k2tpra/vblNpOnU1SkfT4PzPRk4oarUOQ9w6x8eHpXGXkck22sdPmAKNJTv7YjLSL2yLdGWKV8uxFLDHfagMPQf8e9yK3BknsJfYAU2g3PvwZIBQcrEGPkG75RvTbzFVyjPZPjDcNjvM5X1LiRTEtP9ygkGOhO+Gm+I44l9B/9Lu/mWEtonKhq5xl/aF02vutON1NKVDfmNa2mQ7HwxysKsjRAIyMBfwmVC+ave5V153iQH/3zGSx1HWaNps4IMS6/G7PsOFExTvN8ebSrQBbAdMRWoroOorW8pCIacIII5r42gCJm0ph2hcbQOL8GHbwVhc0pHhuoO7O3575YO4AsmsFueurqqqcVQY9Hbj6L5rL92nU5gphHEhb9kRJN70j6rv0mdlgXOvrXnZ4ZjIiTY+WS3kN9ecKOjVy/E7k9I3BJvPw3E6xt4cEWZsvLOdRD39ENNmUjGdRZHlU6TLleOx/cpzAhCnacyow/mG9Oijumhr8JY9PnnHJiTLBjxbtGbVoOTc4BFqYloZr2i22Luvi7p6Qf3XHtkMo02u3aaYL8u2GniIo4k3swsJzQfuYzC5SJ61i7+tzq9t/LxB1t8JDVUlRhMI20KGPauz/JJOo5ss8gkDi2y/egV3aV1/LUTzg3NWL9sXNtBaVvc2RrAOVn4pjW4BWoy0rKbT2ELfm9jpi0deUt30YwJO4g/zqutWehjj0htJ3Bbha1nzN3JZmRDONawDhao3QzYxwHHmc2VoHtUYWr150FTV7DCYVZ9uhmSxaCBvihe3Q8YSzLi+YS5EF3CgXmAUoE+hKsQk0C0drvO6nmxJTiIplxpOiscNSY0TL6crTzRGCmSLnvHOa/mcnm+8XBB/k1I1tCKjntQsjJMwlZVKP1rhvFNyIJrrnBTRZdrax9O2oBvmSH4drfqgLDmKh+mkO+9NQt3pCTCLZGWFvB3jdk27gSVNC/12uFfZof84uzlPWLN5zyzFZzIn6M5goAL+O910Jk2u7scNSHXejFC0LtYQepFMvpzKF9BY2i1We8hj3f8iDxamQpgA7/Ohl73tJfLZ1ByLlfdCLSdbO2gBYYFxOtt/GzAxfrNMa8nOKV5Vb3FBdA4IkfhQ2QrDKkJL+aBcssOmqNwUcL3C2VA11EWRenKEpwY/daxh8Kfw6nBnV6SWa/HnwKHxoNOYJVYmagh++Y/7ZYkv2uRKBP+DtVa697Yft5nYr82YjlrQA7nBGR5SUtsD/uIjDxRpMrXuFnGg3hsPo1HvTLFFUWeJH50JmAuDCA9tYmOrUVMkP+9g/lprkeHMaIsdrAXMvADeHtSIZzqqvIWUIuyLMbiMJalmsjhObbwo8gt9MNEosQT8dsExxQ4rkf2LhmOtYU7EH6wAwjPWgGUlJ/VDzmhxXQTfdO2yPOV5FfkvqAm3xJsqnf2INuPfwrOn4RWrw2qR36rNfcOGwRI8sloUpV54gkh7pszWZCxYqsbZsnnqeUREIJBqsMxP0tx7cRzC3Rrjc7+9Y5gmiRdPjHIZbTFie/vx8kR9X77y+p3Dzirq9qjACAW+08Z5MdQ2UVxL2lg6niGuq9wIa/TnMVbMPXY76Og8waBjZf6GcI+1DATPRdm9aQLK4Bb/divsOzzW1jyw7toQSfgk0cDpYqIetuixQf5+7f3WX3GXTistduUkSihA6+9HpkEnCykT4uGq1Ek+Tm1K0IkH8pOYc/kwqLqgl5AwrhoAsYwSVOgT2UxM+/pbBpRo9MfJv64OjuBXSKchv+NMcyssMZB674jdGceA=="}', - public_key: - '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}', - private_key: - '{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}', - passphrase: 'minus mobile dexter forest elvis', -}; - -test('decrypt v1 private key', async () => { - const keychain = new Keychain(pwdv1web.userId); - const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase); - expect(decrypted).toBe(pwdv1web.private_key); - const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase); - expect(encrypted.iv).toHaveLength(base64Length(12)); -}); - -const pwdv2web: typeof pwdv1web = { - userId: 'BFqA4FAHKX4qEywfJ', - persisted: - '{"iv":"vO0oF7jr9jGCuBqJ","ciphertext":"b6e8YDZWn+aHUTJOvs7HSPGv9TvXB/s5Dc8fVzJYGdqaoSDKC5s6cIi6TNeSPblJXYQ2dkdLjgv/VzF6q6/E3/y5px90uHzYXDw92QGwjAHW9jsekSKoxnszMD64iB8O2RCFQPepmx15Upuo8NdnyGno+sbp9G4DkKcB2XrAw53V9BVpJixGc+MNWuRe+TyW49CkDL9kdukPPFYzi5p0qtgUoQ1IiqTUYWU1vVbYia76xEKuHrG+xjdXpXeLLu6FewO1xCWUli32ydQHoyty2WDV3ITfddUPIiPSNjspJXnp7cBKuabap4WuwLWfonv25PjZWICYF4l10Q1gESOmR7sOUBrKVBTQvZfPpZmSHhfMfqeDzuODyNTDAvBHavlIcR11xzIHiy/dofTUL8O+ctiQC+P0i3cN7/hrDXfb+mJHtrKGEO9beSbpYsar4nwaWI4irwFgbg6Ltd2spf7W00KFoQEhTfpZum86wcgT0vxaQRbeH5zHvWgPH8aWvc4NmXV/Tcnf9P5rd/mbSl8F6jk2O7ygkZdkDfmUAxOQ9tjfz16KV9zm+Tlx5V276/a2wfiZ73LpNwWA6DS8+3ncN2nhFsjn0Ma+I4gyrRm1jnr7NUsLg+E+A7cEaN60JEf5IOcFmJ94emh945qv3gpQyUYsCnNR/wBSc3bgr/qC9vK3jKObfYKygt5KaMoLE9YBXqyaZ5Sht63d6sIq8GJis01eUx3+90QyOYKulL0nCiSLREZyyv5LPDbVrcJIV63CuRibbVtCyrQAKRqxm8lyw3gCKNRgNDxTWMnPJd4BN4orq1rI1PDM2zADoSjQEEkf9wo/qaHey1sNufOT8pDu/J9+hKlrwyVFc+x/ApGBxQjLK6NPKDBCJvuqwDu17EHxRlfber/KyyJjK/ymo0xLsXgvjxci4tCrtnaxuqeLXP8W5Bm7akvRKjJj1mY9pztP6vIdgwSRNnPQMRrz36s8czd0e0ba/CpUnWfTUfVE3/MgmWry11KmnyAPDs/jekgmKRKVzkyxQxl+vPjgA3rAlEof2UMqmMaIFQOEbIdFRiy9PddPAVDwDZUHpPo8bFWa22P8g9f0F8d9rLwxUoWfIRB2jqwBea553lvwjt+HXYx1sAUqG2cXagXypi4nT2dRrooDoyeZxrOmtEX4oAIs9yeeQzPRdUlJMpYLnhAv9cjuouEN6RXUuyILUb6fTn2EbE/B147SojFwCMUfp4IC6cPjpwi/O7v0cC9OjcE9/YzWYTUGq2SC5CYZ6TQ9SVwmidcAq8lD4O0gLeYd1lKis3W42q9mIScxKf9l49g6v94kecvoWQ2p5WiUItwnHoTGNBZHH0h9fQbDVorZ7gS6/ZdhoDWUHOjDwN/yP8y3BV2OBGgw0RXhvM13W+G9g/Zj4aQ44T0alCiA34miELY5paznFdjzkk7wo7RIVRllESLf1xvbPRHDlGG5RYs1z1ueGBVGtkC826yTsN6nFdaxRd2Ntvgmbmpn3AiEoLxrAK4YbJ23X30W6iz5gen8j9O2N79vE+jvTcgBtP/38J0Lyy0OPQfH/VbrcEzrMdsPbyU87tORrhsUFg+NX1H0aVfTgLRM0h1kCYAtr2HpfIo4QCRHT8ar12aOKb69q8gNCdMf74S0krKIxiRAB8VgVMc4xCJQzjizOsosEj+sI6hMtrepjecf0guoGR5+qeYZ/WfREHLK9RRRzOXQntKdREt7dMJtYYlRZ50hT++tWoA6w4S0AyqCV7Q37e/YQtadkOkqwPo0F/TE2vuqmeRa14ZiZRh7ilMWv2cZ4AUGAWEtPkVs8lWlS5WvuRz0Jr3Qy5QnllPzSkzCotPA1+Ecap05SCnAgwG/RUt3UaUIU7+FDL/UHcmMX0AK4ec1MG98C6s31fmCgSTilEsKOj9zCMCGYfmp+MnX9YSqqrFdA8E2OLjscOkh17/FliGzx16QcAglTbU51DDhU8e0n/Kc6AHkylvmydfEm/PP23jGCO8hjaafWLcK6NRNxmK2EsHIut6sb0eUHwDBGYTdJPXvN961j41OIG/Piy0qj+329V9wkrjxGqklcHCVYHCyJX4/PLo3ieBPb4gf2xApFuaxGjsnufe1QY9jitSo8SPGBmcFfZOWdNgtEHK3HfrrSH30H4aMI9wRcbni36RjeRo9LjRgcPFeTljLo44323EjWk1UJ4P+h+zz5LLjXEFDKLPUyFe+AA/blisN1XRbwpvmAWBPVrZuZ4s=","salt":"v2:BFqA4FAHKX4qEywfJ:5b36c4cc-4e7a-497d-b4b0-a1df863c65c4","iterations":100000}', - public_key: - '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ"}', - private_key: - '{"alg":"RSA-OAEP-256","d":"BfaLapcFxwaKxDjeSCR4wb6H5xF5QCw36QvqX8JvqUX_NA4xFtn6RqgdVhMXep3DW641cA1xb8oIxRGt2YL1kb0gN97fdINtOMXSumebZODtYf5EZ-inJtIdDXcy2MjsSMZIqUwuQ5gRq90SxSpKE9eRrEfuuh5nxF5BWwNp65ZW4rCsEiInmnrFW4ERtyTHJ1catbJ_lj71lcVzC-1St6jeXcdinG2FtH0hJ4_ijzp6sAqm1xC9XMhF2g4tZeyvVg9dGBzyLyxq78zNAPJ93ungjc0ITJ27g3IaP7EUX7SxjHasU37j7KOIOGmTswkxIEoVQrlep6xU1RFkPphJNQ","dp":"sJhWI8YfVUr1N5vTr255xJ3Bo84320NWAl9MUhd87XoV3soGO0lmC1bYdrNvIU7wjJ3PxdpSrJ2HQDY5dR088RcmD4J1i3PFJUXVW0A_YkTIt2k8x5m1yF6npS0AgxgwauFxGcE7KgO2vtBn5SHMUOB-gmgUYhDRVB8NdQ3Z550","dq":"jXuFkyialQd264s-RW5adF637uYgh1pnutcQ1wI8HzVAcr3L36xrjQxDYMY6n5uNJJE0I3LyIe9Ez4j_83wiV3nsjFhSj-i7Doiy2k2zHRBqS9ajg933KVZVWD6fN7nr31jF_cdbkzzldkpgxXi3EeM2_0TN7kt1s1kvSeuEoME","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ","p":"4yd_HHhnSAh-eGnF31n0ihyr2M7UBc3m2IDxbQBTohi3qUGD1x5YRlPtx1zpr0vZgoTkDUZ6DPbdXp6QBRku6SOKDrHA0jFiVdqmmVEPGKa1fLVtRIl_kDI-02-F4lvYlTfIQ4qchT64VSvwJnnxUlsuodfbohZi4zYaIMvzXZs","q":"xYTnIC-3Cy31zs0ZC5YTCTYp9KWD__jtcZjBmoqpc_dyhPa-Xzz2ty-6ytP_23mGi2vOB4rZjE9v2gh18l4AzhIrqR9ns4pxYZG4S2O3Si8oHavMJE4mx7VN6Lmgqs3DD45tA7zHbj8hnBg1It2aftWkvzxGx2yQe_r0JomXA7M","qi":"JF4tOm05ianqiNk2jtArg9O4kL8ZjiNgBA97Yu05b_f7k4MMKZqmRihCvzSMpk6w4pqkIdmnSSw19feNCXDv4QHFv4eSsTvBD8g_zmwIkMFyJzeC34J5bgafteZ1RpXsxN5CAAjyhJObEll3CY9B9zZTCLuZDr_616RWDvK1_w4"}', - passphrase: 'prize fluid crystal small jaguar lunar bonus absent destroy settle carbon ignore', -}; - -test('decrypt v2 private key', async () => { - const keychain = new Keychain(pwdv2web.userId); - const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); - expect(decrypted).toBe(pwdv2web.private_key); -}); - -test('roundtrip v2 private key', async () => { - const keychain = new Keychain(pwdv2web.userId); - const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); - expect(decrypted).toBe(pwdv2web.private_key); - const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase); - expect(encrypted.iv).toHaveLength(base64Length(12)); +describe('Keychain', () => { + /** + * Calculates the length of a base64-encoded string given the length of the original byte array. + * + * @param byteLength The length of the byte array to be encoded. + * @returns The length of the resulting base64-encoded string. + */ + const base64Length = (byteLength: number) => { + return ((4 * byteLength) / 3 + 3) & ~3; + }; + + const pwdv1web = { + userId: 'userE2EE', + persisted: + '{"$binary":"JtJhD4JHyNS5dnf5+qj1IxbEGa6bmxlgvX6r0+LhvbnD15KmyTN/HBi0qip4GYJaolVhk7Y5YuB5qT/cRxKv6+2Qe+4Rc6DorInx1+V2tdI2JWkQoVi91GpN7ezy8ghOJ5nbsPn4xkv8jVZU2itAIAuqurKvL1hPUMsGsSnxazC6zEsInv0R/9Y2sdtm5L6ISSjo6iIqUaUEPw0gqt6y54ZSKOdTtI7xlpzJXrNdirrERiXDBj8mbzH2JG9OqfXHZWxm+NtaYLnDuwhN8M9TFj72blBU4c6z4TSb2Gc5aoAkQ9t0tlGZXbjxoa0Y0OT5UqQYVWuqDHPNyzl9ZBz6ZQ735laQZofp5roaH1g2tD9AuBO6iTvbuhwuBGdsbFj79FE5dXXKfcH44enNqL0EYGB81z1+4wuWjhyjnEuC9KXJQNqiCmftO0JQzzEqrrnYpOcFItsJPJ2TNbP2yUuITJzewjkqq4k2tpra/vblNpOnU1SkfT4PzPRk4oarUOQ9w6x8eHpXGXkck22sdPmAKNJTv7YjLSL2yLdGWKV8uxFLDHfagMPQf8e9yK3BknsJfYAU2g3PvwZIBQcrEGPkG75RvTbzFVyjPZPjDcNjvM5X1LiRTEtP9ygkGOhO+Gm+I44l9B/9Lu/mWEtonKhq5xl/aF02vutON1NKVDfmNa2mQ7HwxysKsjRAIyMBfwmVC+ave5V153iQH/3zGSx1HWaNps4IMS6/G7PsOFExTvN8ebSrQBbAdMRWoroOorW8pCIacIII5r42gCJm0ph2hcbQOL8GHbwVhc0pHhuoO7O3575YO4AsmsFueurqqqcVQY9Hbj6L5rL92nU5gphHEhb9kRJN70j6rv0mdlgXOvrXnZ4ZjIiTY+WS3kN9ecKOjVy/E7k9I3BJvPw3E6xt4cEWZsvLOdRD39ENNmUjGdRZHlU6TLleOx/cpzAhCnacyow/mG9Oijumhr8JY9PnnHJiTLBjxbtGbVoOTc4BFqYloZr2i22Luvi7p6Qf3XHtkMo02u3aaYL8u2GniIo4k3swsJzQfuYzC5SJ61i7+tzq9t/LxB1t8JDVUlRhMI20KGPauz/JJOo5ss8gkDi2y/egV3aV1/LUTzg3NWL9sXNtBaVvc2RrAOVn4pjW4BWoy0rKbT2ELfm9jpi0deUt30YwJO4g/zqutWehjj0htJ3Bbha1nzN3JZmRDONawDhao3QzYxwHHmc2VoHtUYWr150FTV7DCYVZ9uhmSxaCBvihe3Q8YSzLi+YS5EF3CgXmAUoE+hKsQk0C0drvO6nmxJTiIplxpOiscNSY0TL6crTzRGCmSLnvHOa/mcnm+8XBB/k1I1tCKjntQsjJMwlZVKP1rhvFNyIJrrnBTRZdrax9O2oBvmSH4drfqgLDmKh+mkO+9NQt3pCTCLZGWFvB3jdk27gSVNC/12uFfZof84uzlPWLN5zyzFZzIn6M5goAL+O910Jk2u7scNSHXejFC0LtYQepFMvpzKF9BY2i1We8hj3f8iDxamQpgA7/Ohl73tJfLZ1ByLlfdCLSdbO2gBYYFxOtt/GzAxfrNMa8nOKV5Vb3FBdA4IkfhQ2QrDKkJL+aBcssOmqNwUcL3C2VA11EWRenKEpwY/daxh8Kfw6nBnV6SWa/HnwKHxoNOYJVYmagh++Y/7ZYkv2uRKBP+DtVa697Yft5nYr82YjlrQA7nBGR5SUtsD/uIjDxRpMrXuFnGg3hsPo1HvTLFFUWeJH50JmAuDCA9tYmOrUVMkP+9g/lprkeHMaIsdrAXMvADeHtSIZzqqvIWUIuyLMbiMJalmsjhObbwo8gt9MNEosQT8dsExxQ4rkf2LhmOtYU7EH6wAwjPWgGUlJ/VDzmhxXQTfdO2yPOV5FfkvqAm3xJsqnf2INuPfwrOn4RWrw2qR36rNfcOGwRI8sloUpV54gkh7pszWZCxYqsbZsnnqeUREIJBqsMxP0tx7cRzC3Rrjc7+9Y5gmiRdPjHIZbTFie/vx8kR9X77y+p3Dzirq9qjACAW+08Z5MdQ2UVxL2lg6niGuq9wIa/TnMVbMPXY76Og8waBjZf6GcI+1DATPRdm9aQLK4Bb/divsOzzW1jyw7toQSfgk0cDpYqIetuixQf5+7f3WX3GXTistduUkSihA6+9HpkEnCykT4uGq1Ek+Tm1K0IkH8pOYc/kwqLqgl5AwrhoAsYwSVOgT2UxM+/pbBpRo9MfJv64OjuBXSKchv+NMcyssMZB674jdGceA=="}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"IpABtkEzPenNwQng105CKD5NndKj1msi_CXMibzhQk37rbg3xXi9w3KPC8th5JGnb5rl6AxxI-rZrytzUD3C8AVCjes3tSG33BdA1FkFITFSSeD6_ck2pbtxDDVAARHK431VDHjdPHz11Ui3kQZHiNGCtwKGMf9Zts1eg1WjfQnQw2ta4-38mwHpq-4Cm_F1brNTTAu5XlHMws4-TDlYhY3nFU2XvoiR2RPDbMddtvXpDZIVo9s7h3jcS4JxHeJd7mWfwcR_Wf0ArRJIhckgPQtTAAjADNpw_HAdERfJyOAJUnxtHkv4uTu_k23qDpPGEi8euFpQ_1UD8B_Z1Rxylw","dp":"OS3zu_VYJZmOXl1dRXxYGP69MR4YQ3TFJ58HFIxvebD060byGHL-mwf0R6-a1hBkHfSeUI9iPipEcjQeevasPqm5CG8eYMvGU2vhsoq1gfY79rsoKjnThCO3XiUbNeM-G9MRKMRa3ooQ8fUVHyEWKFo1ajoFbVHxZuqTAOgrYT8","dq":"yXtWRU1vM5imQJhIZBt5BO1Rfn-koHTvTM3c5QDdPLyNoGTKTyeoT3P9clN6qevJKTyJJTWiwuz8ZECSksh_m9STCY1ry2HqlF2EKdCZnTQzhoJvb6d7547Witc9eh2rBjsILSxVBadLzOFe8opkkQkdkM_gN_Rr3TtXEAo1vn8","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"qVLMv2Iwm_Hyhlnh4etNlHEiCXBzJWbqMwOZ6pz_JuZY2HiqbLmSfBtpvwBKZvcmP92BqLl-qZuLV_bJD_11UBS3gR6ykgU-RsTz1L-V8vA2QZEyULP4DqkMcRCV_7WE_sn_ScePgKszx1284gnngct_1Tv37zB6Ifz7gb1THRwAqOGcE2htea4yQEhyX8ZAl_-95DTWLbXqEAuofqDpXMcQo487VezBWIaDdfw2VX0qi6kM-pt03Gx8uMniyAjhK1G8Dro3wgAtz4PNIwOsdXEvWTSyoXLVMsIuZeO9OGdJKXnZFtVEMzXLyQTD1LjXlsM_TF09fbkN41Tz12ojmQ","p":"0GJaXeKlxgcz6pX0DdwtWG38x9vN2wfLrN3F8N_0stzyPMjMpLGXOdGq1k1V6FROYvLHZsqdCpziwJ3a1PQaGUg2lO-KeBghlbDk4xfYbzSSPhVdwvUT27dysd3-_TsBvNpVCqCLb9Wgl8f0jrrRmRTSztYSLw3ckL939OJoe0M","q":"0AOMQqdGlz0Tm81uqpzCuQcQLMj-IhmPIMuuTnIU55KCmEwmlf0mkgesj-EEBsC1h6ScC5fvznGNvSGqVQAP5ANNZxGiB73q-2YgH3FpuEeHekufl260E_9tgIuqjtCv-eT_cLUhnRNyuP2ZiqRZsBWLuaQYkTubyGRi6izoofM","qi":"FXbIXivKdh0VBgMtLe5f1OjzyrSW_IfIvz8ZM66F4tUTxnNKk5vSb_q2NPyIOVYbdonuVguX-0VO54Ct16k8VdpQSMmUxGbyQAtIck2IzEzpfbRJgn06wiAI3j8q1nRFhrzhfrpJWVyuTiXBgaeOLWBz8fBpjDU7rptmcoU3tZ4"}', + passphrase: 'minus mobile dexter forest elvis', + }; + + test('decrypt v1 private key', async () => { + const keychain = new Keychain(pwdv1web.userId); + const decrypted = await keychain.decryptKey(pwdv1web.persisted, pwdv1web.passphrase); + expect(decrypted).toBe(pwdv1web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv1web.passphrase); + expect(encrypted.iv).toHaveLength(base64Length(12)); + }); + + const pwdv2web: typeof pwdv1web = { + userId: 'BFqA4FAHKX4qEywfJ', + persisted: + '{"iv":"vO0oF7jr9jGCuBqJ","ciphertext":"b6e8YDZWn+aHUTJOvs7HSPGv9TvXB/s5Dc8fVzJYGdqaoSDKC5s6cIi6TNeSPblJXYQ2dkdLjgv/VzF6q6/E3/y5px90uHzYXDw92QGwjAHW9jsekSKoxnszMD64iB8O2RCFQPepmx15Upuo8NdnyGno+sbp9G4DkKcB2XrAw53V9BVpJixGc+MNWuRe+TyW49CkDL9kdukPPFYzi5p0qtgUoQ1IiqTUYWU1vVbYia76xEKuHrG+xjdXpXeLLu6FewO1xCWUli32ydQHoyty2WDV3ITfddUPIiPSNjspJXnp7cBKuabap4WuwLWfonv25PjZWICYF4l10Q1gESOmR7sOUBrKVBTQvZfPpZmSHhfMfqeDzuODyNTDAvBHavlIcR11xzIHiy/dofTUL8O+ctiQC+P0i3cN7/hrDXfb+mJHtrKGEO9beSbpYsar4nwaWI4irwFgbg6Ltd2spf7W00KFoQEhTfpZum86wcgT0vxaQRbeH5zHvWgPH8aWvc4NmXV/Tcnf9P5rd/mbSl8F6jk2O7ygkZdkDfmUAxOQ9tjfz16KV9zm+Tlx5V276/a2wfiZ73LpNwWA6DS8+3ncN2nhFsjn0Ma+I4gyrRm1jnr7NUsLg+E+A7cEaN60JEf5IOcFmJ94emh945qv3gpQyUYsCnNR/wBSc3bgr/qC9vK3jKObfYKygt5KaMoLE9YBXqyaZ5Sht63d6sIq8GJis01eUx3+90QyOYKulL0nCiSLREZyyv5LPDbVrcJIV63CuRibbVtCyrQAKRqxm8lyw3gCKNRgNDxTWMnPJd4BN4orq1rI1PDM2zADoSjQEEkf9wo/qaHey1sNufOT8pDu/J9+hKlrwyVFc+x/ApGBxQjLK6NPKDBCJvuqwDu17EHxRlfber/KyyJjK/ymo0xLsXgvjxci4tCrtnaxuqeLXP8W5Bm7akvRKjJj1mY9pztP6vIdgwSRNnPQMRrz36s8czd0e0ba/CpUnWfTUfVE3/MgmWry11KmnyAPDs/jekgmKRKVzkyxQxl+vPjgA3rAlEof2UMqmMaIFQOEbIdFRiy9PddPAVDwDZUHpPo8bFWa22P8g9f0F8d9rLwxUoWfIRB2jqwBea553lvwjt+HXYx1sAUqG2cXagXypi4nT2dRrooDoyeZxrOmtEX4oAIs9yeeQzPRdUlJMpYLnhAv9cjuouEN6RXUuyILUb6fTn2EbE/B147SojFwCMUfp4IC6cPjpwi/O7v0cC9OjcE9/YzWYTUGq2SC5CYZ6TQ9SVwmidcAq8lD4O0gLeYd1lKis3W42q9mIScxKf9l49g6v94kecvoWQ2p5WiUItwnHoTGNBZHH0h9fQbDVorZ7gS6/ZdhoDWUHOjDwN/yP8y3BV2OBGgw0RXhvM13W+G9g/Zj4aQ44T0alCiA34miELY5paznFdjzkk7wo7RIVRllESLf1xvbPRHDlGG5RYs1z1ueGBVGtkC826yTsN6nFdaxRd2Ntvgmbmpn3AiEoLxrAK4YbJ23X30W6iz5gen8j9O2N79vE+jvTcgBtP/38J0Lyy0OPQfH/VbrcEzrMdsPbyU87tORrhsUFg+NX1H0aVfTgLRM0h1kCYAtr2HpfIo4QCRHT8ar12aOKb69q8gNCdMf74S0krKIxiRAB8VgVMc4xCJQzjizOsosEj+sI6hMtrepjecf0guoGR5+qeYZ/WfREHLK9RRRzOXQntKdREt7dMJtYYlRZ50hT++tWoA6w4S0AyqCV7Q37e/YQtadkOkqwPo0F/TE2vuqmeRa14ZiZRh7ilMWv2cZ4AUGAWEtPkVs8lWlS5WvuRz0Jr3Qy5QnllPzSkzCotPA1+Ecap05SCnAgwG/RUt3UaUIU7+FDL/UHcmMX0AK4ec1MG98C6s31fmCgSTilEsKOj9zCMCGYfmp+MnX9YSqqrFdA8E2OLjscOkh17/FliGzx16QcAglTbU51DDhU8e0n/Kc6AHkylvmydfEm/PP23jGCO8hjaafWLcK6NRNxmK2EsHIut6sb0eUHwDBGYTdJPXvN961j41OIG/Piy0qj+329V9wkrjxGqklcHCVYHCyJX4/PLo3ieBPb4gf2xApFuaxGjsnufe1QY9jitSo8SPGBmcFfZOWdNgtEHK3HfrrSH30H4aMI9wRcbni36RjeRo9LjRgcPFeTljLo44323EjWk1UJ4P+h+zz5LLjXEFDKLPUyFe+AA/blisN1XRbwpvmAWBPVrZuZ4s=","salt":"v2:BFqA4FAHKX4qEywfJ:5b36c4cc-4e7a-497d-b4b0-a1df863c65c4","iterations":100000}', + public_key: + '{"alg":"RSA-OAEP-256","e":"AQAB","ext":true,"key_ops":["encrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ"}', + private_key: + '{"alg":"RSA-OAEP-256","d":"BfaLapcFxwaKxDjeSCR4wb6H5xF5QCw36QvqX8JvqUX_NA4xFtn6RqgdVhMXep3DW641cA1xb8oIxRGt2YL1kb0gN97fdINtOMXSumebZODtYf5EZ-inJtIdDXcy2MjsSMZIqUwuQ5gRq90SxSpKE9eRrEfuuh5nxF5BWwNp65ZW4rCsEiInmnrFW4ERtyTHJ1catbJ_lj71lcVzC-1St6jeXcdinG2FtH0hJ4_ijzp6sAqm1xC9XMhF2g4tZeyvVg9dGBzyLyxq78zNAPJ93ungjc0ITJ27g3IaP7EUX7SxjHasU37j7KOIOGmTswkxIEoVQrlep6xU1RFkPphJNQ","dp":"sJhWI8YfVUr1N5vTr255xJ3Bo84320NWAl9MUhd87XoV3soGO0lmC1bYdrNvIU7wjJ3PxdpSrJ2HQDY5dR088RcmD4J1i3PFJUXVW0A_YkTIt2k8x5m1yF6npS0AgxgwauFxGcE7KgO2vtBn5SHMUOB-gmgUYhDRVB8NdQ3Z550","dq":"jXuFkyialQd264s-RW5adF637uYgh1pnutcQ1wI8HzVAcr3L36xrjQxDYMY6n5uNJJE0I3LyIe9Ez4j_83wiV3nsjFhSj-i7Doiy2k2zHRBqS9ajg933KVZVWD6fN7nr31jF_cdbkzzldkpgxXi3EeM2_0TN7kt1s1kvSeuEoME","e":"AQAB","ext":true,"key_ops":["decrypt"],"kty":"RSA","n":"r0NSQ6ZOz2F-lXBRTwGBACu5dih-wQBDGf7gkvTASzqtC545BNRjLO9aGbhma-zBUc8JSJdZzpJUiKx0vHh9RG_RjE5znE9HgK66J1lSJNJQMjM0WZSRs1C95A-93k_MwrwJmIoWVr3a1xlOw1eO0Fwc0pr4LQsIDnfrkmxZQJTlzQXoetFlZv4HmJlM0yeMoeBBgMo-ZNKSJW_MtlwbV6SXpHLYE9I1bWO8ooIi84J8xGsjmHK3DAdmD_wNqISFK4Da3YoNSlPa_jdakAYy3nhlR0DEzz_bcxb_jXwpAH3G2DCMEYwRSap25ZytXVfN9mOkT7NYEMiVVdH4nrBEYQ","p":"4yd_HHhnSAh-eGnF31n0ihyr2M7UBc3m2IDxbQBTohi3qUGD1x5YRlPtx1zpr0vZgoTkDUZ6DPbdXp6QBRku6SOKDrHA0jFiVdqmmVEPGKa1fLVtRIl_kDI-02-F4lvYlTfIQ4qchT64VSvwJnnxUlsuodfbohZi4zYaIMvzXZs","q":"xYTnIC-3Cy31zs0ZC5YTCTYp9KWD__jtcZjBmoqpc_dyhPa-Xzz2ty-6ytP_23mGi2vOB4rZjE9v2gh18l4AzhIrqR9ns4pxYZG4S2O3Si8oHavMJE4mx7VN6Lmgqs3DD45tA7zHbj8hnBg1It2aftWkvzxGx2yQe_r0JomXA7M","qi":"JF4tOm05ianqiNk2jtArg9O4kL8ZjiNgBA97Yu05b_f7k4MMKZqmRihCvzSMpk6w4pqkIdmnSSw19feNCXDv4QHFv4eSsTvBD8g_zmwIkMFyJzeC34J5bgafteZ1RpXsxN5CAAjyhJObEll3CY9B9zZTCLuZDr_616RWDvK1_w4"}', + passphrase: 'prize fluid crystal small jaguar lunar bonus absent destroy settle carbon ignore', + }; + + test('decrypt v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); + }); + + test('roundtrip v2 private key', async () => { + const keychain = new Keychain(pwdv2web.userId); + const decrypted = await keychain.decryptKey(pwdv2web.persisted, pwdv2web.passphrase); + expect(decrypted).toBe(pwdv2web.private_key); + const encrypted = await keychain.encryptKey(decrypted, pwdv2web.passphrase); + expect(encrypted.iv).toHaveLength(base64Length(12)); + }); }); From 119e1f882c724c37d22b2d62a4f6d8327bbfb04b Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 16 Oct 2025 16:36:42 -0300 Subject: [PATCH 227/251] refactor(e2e): improve organization of passphrase management E2E tests --- .../e2ee-passphrase-management.spec.ts | 191 +++++++++--------- 1 file changed, 95 insertions(+), 96 deletions(-) diff --git a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts index 2d759fc052619..71e2f4ed96c55 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/e2ee-passphrase-management.spec.ts @@ -42,132 +42,131 @@ test.describe('E2EE Passphrase Management - Initial Setup', () => { await api.post('/settings/E2E_Enabled_Default_PrivateRooms', { value: originalSettings.E2E_Enabled_Default_PrivateRooms }); }); - test.beforeEach(async ({ page, api }) => { - const loginPage = new LoginPage(page); + test.describe('Generate', () => { + test.beforeEach(async ({ page, api }) => { + const loginPage = new LoginPage(page); - await api.post('/method.call/e2e.resetOwnE2EKey', { - message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), - }); + await api.post('/method.call/e2e.resetOwnE2EKey', { + message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), + }); - await page.goto('/home'); - await loginPage.waitForIt(); - await loginPage.loginByUserState(Users.admin); - }); + await page.goto('/home'); + await loginPage.waitForIt(); + await loginPage.loginByUserState(Users.admin); + }); - test('expect the randomly generated password to work', async ({ page }) => { - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - const sidenav = new HomeSidenav(page); + test('expect the randomly generated password to work', async ({ page }) => { + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const sidenav = new HomeSidenav(page); - const password = await setupE2EEPassword(page); + const password = await setupE2EEPassword(page); - // Log out - await sidenav.logout(); + // Log out + await sidenav.logout(); - // Login again - await loginPage.loginByUserState(Users.admin); + // Login again + await loginPage.loginByUserState(Users.admin); - // Enter the saved password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(password); + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(password); - // No error banner - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); - }); + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); - test('expect to manually reset the password', async ({ page }) => { - const accountSecurityPage = new AccountSecurityPage(page); - const loginPage = new LoginPage(page); + test('expect to manually reset the password', async ({ page }) => { + const accountSecurityPage = new AccountSecurityPage(page); + const loginPage = new LoginPage(page); - // Reset the E2EE key to start the flow from the beginning - await accountSecurityPage.goto(); - await accountSecurityPage.resetE2EEPassword(); + // Reset the E2EE key to start the flow from the beginning + await accountSecurityPage.goto(); + await accountSecurityPage.resetE2EEPassword(); - await loginPage.loginByUserState(Users.admin); - }); + await loginPage.loginByUserState(Users.admin); + }); - test('should reset e2e password from the modal', async ({ page }) => { - const sidenav = new HomeSidenav(page); - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); + test('should reset e2e password from the modal', async ({ page }) => { + const sidenav = new HomeSidenav(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const resetE2EEPasswordModal = new ResetE2EEPasswordModal(page); - await setupE2EEPassword(page); + await setupE2EEPassword(page); - // Logout - await sidenav.logout(); + // Logout + await sidenav.logout(); - // Login again - await loginPage.loginByUserState(Users.admin); + // Login again + await loginPage.loginByUserState(Users.admin); - // Reset E2EE password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.forgotPassword(); - await resetE2EEPasswordModal.confirmReset(); + // Reset E2EE password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.forgotPassword(); + await resetE2EEPasswordModal.confirmReset(); - // restore login - await loginPage.loginByUserState(Users.admin); - }); + // restore login + await loginPage.loginByUserState(Users.admin); + }); - test('expect to manually set a new password', async ({ page }) => { - const accountSecurityPage = new AccountSecurityPage(page); - const loginPage = new LoginPage(page); - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); - const sidenav = new HomeSidenav(page); + test('expect to manually set a new password', async ({ page }) => { + const accountSecurityPage = new AccountSecurityPage(page); + const loginPage = new LoginPage(page); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const sidenav = new HomeSidenav(page); - const newPassword = faker.string.uuid(); + const newPassword = faker.string.uuid(); - await setupE2EEPassword(page); + await setupE2EEPassword(page); - // Set a new password - await accountSecurityPage.goto(); - await accountSecurityPage.setE2EEPassword(newPassword); - await accountSecurityPage.close(); + // Set a new password + await accountSecurityPage.goto(); + await accountSecurityPage.setE2EEPassword(newPassword); + await accountSecurityPage.close(); - // Log out - await sidenav.logout(); + // Log out + await sidenav.logout(); - // Login again - await loginPage.loginByUserState(Users.admin); + // Login again + await loginPage.loginByUserState(Users.admin); - // Enter the saved password - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(newPassword); + // Enter the saved password + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword(newPassword); - // No error banner - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + // No error banner + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); }); -}); -test.describe('E2EE Passphrase Management - Recovery', () => { - test.use({ - storageState: { - ...Users.userE2EE.state, - origins: Users.userE2EE.state.origins.map((origin) => ({ - ...origin, - localStorage: origin.localStorage.filter((item) => !['e2e.randomPassword', 'private_key', 'public_key'].includes(item.name)), - })), - }, - }); + test.describe('Recovery', () => { + test.use({ storageState: Users.userE2EE.state }); - test.beforeEach(async ({ page }) => { - await page.goto('/home'); - }); + test('expect to recover the keys using the recovery key', async ({ page }) => { + await test.step('Recover the keys', async () => { + await page.goto('/home'); + await injectInitialData(); + await restoreState(page, Users.userE2EE); + const sidenav = new HomeSidenav(page); + await sidenav.logout(); - test('expect to recover the keys using the recovery key', async ({ page }) => { - await test.step('Recover the keys', async () => { - const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); - const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); - const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + const loginPage = new LoginPage(page); + await loginPage.loginByUserState(Users.userE2EE, { except: ['private_key', 'public_key'] }); - await enterE2EEPasswordBanner.click(); - await enterE2EEPasswordModal.enterPassword(Users.userE2EE.data.e2ePassword!); - await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + const enterE2EEPasswordBanner = new EnterE2EEPasswordBanner(page); + const enterE2EEPasswordModal = new EnterE2EEPasswordModal(page); + const e2EEKeyDecodeFailureBanner = new E2EEKeyDecodeFailureBanner(page); + + await enterE2EEPasswordBanner.click(); + await enterE2EEPasswordModal.enterPassword('minus mobile dexter forest elvis'); + await e2EEKeyDecodeFailureBanner.expectToNotBeVisible(); + }); }); }); }); From 568b9793ff0c8498c5b24d084cd1b5ce3bde716d Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 10:23:41 -0300 Subject: [PATCH 228/251] chore: format --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 844a5ebdf018b..96faa64c4dff1 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,15 +1,7 @@ import QueryString from 'querystring'; import URL from 'url'; -import type { - IE2EEMessage, - IMessage, - IRoom, - IUser, - IUploadWithUser, - Serialized, - IE2EEPinnedMessage -} from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, Serialized, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { imperativeModal } from '@rocket.chat/ui-client'; From d94b37293d1b21f3d43b0258702153553d593057 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 229/251] refactor: ICodec interface --- apps/meteor/client/lib/e2ee/codec.ts | 54 ++-------------------------- 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/codec.ts b/apps/meteor/client/lib/e2ee/codec.ts index 29a8cd296cd87..1ce365088046b 100644 --- a/apps/meteor/client/lib/e2ee/codec.ts +++ b/apps/meteor/client/lib/e2ee/codec.ts @@ -1,54 +1,4 @@ -import { Base64 } from '@rocket.chat/base64'; - -export type Codec = { +export interface ICodec { decode: (data: TIn) => TOut; encode: (data: TOut) => TEnc; -}; - -// A 256-byte array always encodes to 344 characters in Base64. -const DECODED_LENGTH = 256; -// ((4 * 256 / 3) + 3) & ~3 = 344 -const ENCODED_LENGTH = 344; - -export type PrefixedBase64 = [prefix: string, data: Uint8Array]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const PrefixedBase64: Codec = { - decode: (input) => { - // 1. Validate the input string length - if (input.length < ENCODED_LENGTH) { - throw new RangeError('Invalid input length.'); - } - - // 2. Split the string into its two parts - const prefix = input.slice(0, -ENCODED_LENGTH); - const base64Data = input.slice(-ENCODED_LENGTH); - - // 3. Decode the Base64 string. atob() decodes to a "binary string". - const bytes = Base64.decode(base64Data); - - if (bytes.length !== DECODED_LENGTH) { - // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. - throw new RangeError('Decoded data length is too short.'); - } - - return [prefix, bytes]; - }, - encode: ([prefix, data]) => { - // 1. Validate the input data length - if (data.length !== DECODED_LENGTH) { - throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); - } - - // 2. Convert the byte array (Uint8Array) into a "binary string" - const base64Data = Base64.encode(data); - - if (base64Data.length !== ENCODED_LENGTH) { - // This is a sanity check in case something went wrong during encoding. - throw new RangeError(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); - } - - // 4. Concatenate the prefix and the Base64 string - return prefix + base64Data; - }, -}; +} From 959aa2db2359c38a879a24fafb8a9dc8d8daf649 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 230/251] refactor: Keychain codec handling to a class-based implementation --- apps/meteor/client/lib/e2ee/keychain.ts | 50 +++++++++++++------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index f712a76de4506..ea76780e2aa93 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -1,11 +1,9 @@ import { Base64 } from '@rocket.chat/base64'; import { Binary } from './binary'; -import type { Codec } from './codec'; +import type { ICodec } from './codec'; import * as Pbkdf2 from './pbkdf2'; -export type Serialized = T extends BufferSource ? string : { [K in keyof T]: Serialized }; - /** * Version 1 format: * ``` @@ -37,7 +35,7 @@ interface IStoredKeyV2 { type StoredKey = IStoredKeyV1 | IStoredKeyV2; // eslint-disable-next-line @typescript-eslint/no-redeclare -const StoredKey: Codec = { +const StoredKey: ICodec = { decode: (data) => { const json: unknown = JSON.parse(data); @@ -82,34 +80,39 @@ type EncryptedKey = { options: EncryptedKeyOptions; }; -// eslint-disable-next-line @typescript-eslint/no-redeclare -const EncryptedKey: Codec = { - encode: (encryptedKey) => { +class EncryptedKeyCodec implements ICodec { + userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + encode(encryptedKey: EncryptedKey): IStoredKeyV2 { return { iv: Base64.encode(encryptedKey.content.iv), ciphertext: Base64.encode(encryptedKey.content.ciphertext), salt: encryptedKey.options.salt, iterations: encryptedKey.options.iterations, }; - }, - decode: (storedKey) => { - if ('$binary' in storedKey) { + } + + decode(storedKey: string): EncryptedKey { + const storedKeyObj = StoredKey.decode(storedKey); + if ('$binary' in storedKeyObj) { // v1 - const binary = Base64.decode(storedKey.$binary); - const iv = binary.slice(0, 16); - const ciphertext = binary.slice(16); + const binary = Base64.decode(storedKeyObj.$binary); return { - content: { iv, ciphertext }, + content: { iv: binary.slice(0, 16), ciphertext: binary.slice(16) }, options: { - salt: '', + salt: this.userId, iterations: 1000, }, }; } // v2 - const { iv, ciphertext, salt, iterations } = storedKey; + const { iv, ciphertext, salt, iterations } = storedKeyObj; return { content: { iv: Base64.decode(iv), @@ -120,20 +123,21 @@ const EncryptedKey: Codec = { iterations, }, }; - }, -}; + } +} export class Keychain { - userId: string; + private readonly userId: string; + + private readonly codec: EncryptedKeyCodec; constructor(userId: string) { this.userId = userId; + this.codec = new EncryptedKeyCodec(userId); } async decryptKey(privateKey: string, password: string): Promise { - const storedKey = StoredKey.decode(privateKey); - const { content, options } = EncryptedKey.decode(storedKey); - options.salt = options.salt ? options.salt : this.userId; + const { content, options } = this.codec.decode(privateKey); const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM'; const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); const derivedBits = await Pbkdf2.deriveBits(baseKey, { @@ -154,6 +158,6 @@ export class Keychain { const key = await Pbkdf2.importKey(derivedBits, algorithm); const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.toArrayBuffer(privateKey))); - return EncryptedKey.encode({ content, options: { salt, iterations } }); + return this.codec.encode({ content, options: { salt, iterations } }); } } From 5917c21375b641afc5aba092439f4513b5884a70 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 231/251] feat: PrefixedBase64 codec and tests; update imports to use it --- apps/meteor/client/lib/e2ee/prefixed.spec.ts | 39 +++++++++++++ apps/meteor/client/lib/e2ee/prefixed.ts | 55 +++++++++++++++++++ .../client/lib/e2ee/rocketchat.e2e.room.ts | 3 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/prefixed.spec.ts create mode 100644 apps/meteor/client/lib/e2ee/prefixed.ts diff --git a/apps/meteor/client/lib/e2ee/prefixed.spec.ts b/apps/meteor/client/lib/e2ee/prefixed.spec.ts new file mode 100644 index 0000000000000..89c3bfcd257d3 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/prefixed.spec.ts @@ -0,0 +1,39 @@ +import { Base64 } from '@rocket.chat/base64'; + +import { PrefixedBase64 } from './prefixed'; + +const prefix = '32c9e7917b78'; +const base64 = + 'ibtLAKG9zcQ/NTp+86nVelUjewPbPNW+EC+eagVPVVlbxvWNXkgltrBQB4gDao1Fp6fHUibQB3dirJ4rzy7CViww0o4QjAwPPQMIxZ9DLJhjKnu6bkkOp6Z0/a9g/8Wf/cvP9/bp7tUt7Et4XMmJwIe5iyJZ35lsyduLc8V+YyK8sJiGf4BRagJoBr8xEBgqBWqg6Vwn3qtbbiTs65PqErbaUmSM3Hn6tfkcS6ukLG/DbptW1B9U66IX3fQesj50zWZiJyvxOoxDeHRH9UEStyv9SP8nrFjEKM3TDiakBeDxja6LoN8l3CjP9K/5eg25YqANZAQjlwaCaeTTHndTgQ=='; +const prefixed = `${prefix}${base64}`; + +describe('PrefixedBase64', () => { + it('should roundtrip', () => { + const [kid, decodedKey] = PrefixedBase64.decode(prefixed); + expect(kid).toBe(prefix); + const reencoded = PrefixedBase64.encode([kid, decodedKey]); + expect(reencoded).toBe(prefixed); + }); + + it('should throw on invalid decode input length', () => { + expect(() => PrefixedBase64.decode('too-short')).toThrow(RangeError); + }); + + it('should throw on invalid decoded data length', () => { + const invalidPrefixed = `32c9e7917b78${'A'.repeat(343)}`; // One character short + expect(() => PrefixedBase64.decode(invalidPrefixed)).toThrow(RangeError); + }); + + it('should throw on invalid encode input data length', () => { + const invalidData = new Uint8Array(255); // One byte short + expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError); + }); + + it('should throw on invalid encoded Base64 length', () => { + // This is a bit contrived since our encode implementation always produces valid length, + // but we include it for completeness. + const invalidData = new Uint8Array(256); + jest.spyOn(Base64, 'encode').mockReturnValue('A'.repeat(343)); // One character short + expect(() => PrefixedBase64.encode([prefix, invalidData])).toThrow(RangeError); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/prefixed.ts b/apps/meteor/client/lib/e2ee/prefixed.ts new file mode 100644 index 0000000000000..b80d5d8dfe1f1 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/prefixed.ts @@ -0,0 +1,55 @@ +import { Base64 } from '@rocket.chat/base64'; + +import type { ICodec } from './codec'; + +// A 256-byte array always encodes to 344 characters in Base64. +const DECODED_LENGTH = 256; +// ((4 * 256 / 3) + 3) & ~3 = 344 +const ENCODED_LENGTH = 344; + +/** + * A codec for strings formatted as "prefix + base64(data)", where: + * - `prefix` is an arbitrary-length string + * - `data` is a 256-byte Uint8Array encoded in Base64 + * - the total length of the Base64-encoded data is always 344 characters + * This is used for encoding/decoding E2EE keys with a key ID prefix. + */ +export const PrefixedBase64: ICodec]> = { + decode: (input) => { + // 1. Validate the input string length + if (input.length < ENCODED_LENGTH) { + throw new RangeError('Invalid input length.'); + } + + // 2. Split the string into its two parts + const prefix = input.slice(0, -ENCODED_LENGTH); + const base64Data = input.slice(-ENCODED_LENGTH); + + // 3. Decode the Base64 string. atob() decodes to a "binary string". + const bytes = Base64.decode(base64Data); + + if (bytes.length !== DECODED_LENGTH) { + // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. + throw new RangeError('Decoded data length is too short.'); + } + + return [prefix, bytes]; + }, + encode: ([prefix, data]) => { + // 1. Validate the input data length + if (data.length !== DECODED_LENGTH) { + throw new RangeError(`Input data length is ${data.length}, but expected ${DECODED_LENGTH} bytes.`); + } + + // 2. Convert the byte array (Uint8Array) into a Base64 string + const base64 = Base64.encode(data); + + if (base64.length !== ENCODED_LENGTH) { + // This is a sanity check in case something went wrong during encoding. + throw new RangeError(`Encoded Base64 length is ${base64.length}, but expected ${ENCODED_LENGTH} characters.`); + } + + // 3. Concatenate the prefix and the Base64 string + return prefix + base64; + }, +}; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index fba05a51e6286..6cc017d31b593 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -17,7 +17,6 @@ import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; import * as Aes from './aes'; import { Binary } from './binary'; -import { PrefixedBase64 } from './codec'; import { decodeEncryptedContent } from './content'; import { toArrayBuffer, @@ -28,13 +27,13 @@ import { createSha256HashFromText, } from './helper'; import { createLogger } from './logger'; +import { PrefixedBase64 } from './prefixed'; import { e2e } from './rocketchat.e2e'; import * as Rsa from './rsa'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { Messages, Rooms, Subscriptions } from '../../stores'; -// import { RoomManager } from '../RoomManager'; import { roomCoordinator } from '../rooms/roomCoordinator'; const log = createLogger('E2E:Room'); From c3c8c9f8cac5805c4c851a4adebfa92f74cf7c8f Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 232/251] refactor: AES CryptoKey types and importKey/encrypt/decrypt signatures --- apps/meteor/client/lib/e2ee/aes.ts | 36 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/aes.ts b/apps/meteor/client/lib/e2ee/aes.ts index ecdf8e80ab1b1..0a5b9802c3fa4 100644 --- a/apps/meteor/client/lib/e2ee/aes.ts +++ b/apps/meteor/client/lib/e2ee/aes.ts @@ -1,27 +1,45 @@ -export type Key = CryptoKey & { algorithm: AesKeyAlgorithm }; +type AesKeyAlgorithmLength = 128 | 256; + +interface IAesCbcKeyAlgorithm extends AesKeyAlgorithm { + name: 'AES-CBC'; + length: TLength; +} + +interface IAesGcmKeyAlgorithm extends AesKeyAlgorithm { + name: 'AES-GCM'; + length: TLength; +} + +export type Key = CryptoKey & { + algorithm: IAesCbcKeyAlgorithm<128> | IAesGcmKeyAlgorithm<256>; + extractable: true; + type: 'secret'; + usages: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; +}; type AesEncryptedContent = { iv: Uint8Array; ciphertext: Uint8Array; }; -const JWK_ALG_TO_AES_KEY_ALGORITHM: Record = { +const JWK_ALG_TO_AES_KEY_ALGORITHM = { A256GCM: { name: 'AES-GCM', length: 256 }, A128CBC: { name: 'AES-CBC', length: 128 }, -}; +} satisfies Record; export const importKey = async (jwk: JsonWebKey): Promise => { - if (!('alg' in jwk) || jwk.alg === undefined) { + if (!('alg' in jwk)) { throw new Error('JWK alg property is required to import the key'); } - const algorithm = JWK_ALG_TO_AES_KEY_ALGORITHM[jwk.alg]; - - if (!algorithm) { + if (jwk.alg !== 'A256GCM' && jwk.alg !== 'A128CBC') { throw new Error(`Unsupported JWK alg: ${jwk.alg}`); } + const algorithm = JWK_ALG_TO_AES_KEY_ALGORITHM[jwk.alg]; + const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['encrypt', 'decrypt']); + return key as Key; }; @@ -35,7 +53,7 @@ export const generateKey = async (): Promise => { return key as Key; }; -export const decrypt = async (content: AesEncryptedContent, key: Key): Promise => { +export const decrypt = async (key: Key, content: AesEncryptedContent): Promise => { const decrypted = await crypto.subtle.decrypt( { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, key, @@ -44,7 +62,7 @@ export const decrypt = async (content: AesEncryptedContent, key: Key): Promise, key: Key): Promise => { +export const encrypt = async (key: Key, plaintext: Uint8Array): Promise => { const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16; const iv = crypto.getRandomValues(new Uint8Array(ivLength)); const ciphertext = await crypto.subtle.encrypt({ name: key.algorithm.name, iv }, key, plaintext); From ee4f885e0390f1a6bcae6aa9c27102bbe8438abd Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 233/251] refactor: update call sites for AES encrypt/decrypt --- apps/meteor/client/lib/e2ee/content.spec.ts | 8 ++++---- apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/content.spec.ts b/apps/meteor/client/lib/e2ee/content.spec.ts index f2391599afd18..1352ded8638d0 100644 --- a/apps/meteor/client/lib/e2ee/content.spec.ts +++ b/apps/meteor/client/lib/e2ee/content.spec.ts @@ -70,14 +70,14 @@ const aeskeyv1 = { alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_o test('parse v1 web message', async () => { const parsed = decodeEncryptedContent(msgv1web.content); const key = await importKey(aeskeyv1); - const decrypted = await decrypt(parsed, key); + const decrypted = await decrypt(key, parsed); expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); }); test('parse v1 mobile message', async () => { const parsed = decodeEncryptedContent(msgv1mob.content); const key = await importKey(aeskeyv1); - const decrypted = await decrypt(parsed, key); + const decrypted = await decrypt(key, parsed); expect(decrypted).toMatchInlineSnapshot( `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, ); @@ -86,7 +86,7 @@ test('parse v1 mobile message', async () => { test('parse v1 mobile message from msg field', async () => { const parsed = decodeEncryptedContent(msgv1mob.msg); const key = await importKey(aeskeyv1); - const decrypted = await decrypt(parsed, key); + const decrypted = await decrypt(key, parsed); expect(decrypted).toMatchInlineSnapshot( `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, ); @@ -134,6 +134,6 @@ const aeskeyv2 = { test('parse v2 web message', async () => { const parsed = decodeEncryptedContent(msgv2web.content); const key = await importKey(aeskeyv2); - const decrypted = await decrypt(parsed, key); + const decrypted = await decrypt(key, parsed); expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); }); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 6cc017d31b593..f85f29b253d3e 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -329,8 +329,7 @@ export class E2ERoom extends Emitter { } } catch (error) { this.setState('ERROR'); - span.set('error', error); - span.error('Error fetching group key'); + span.error('Error fetching group key', error); return; } @@ -620,7 +619,7 @@ export class E2ERoom extends Emitter { throw new Error('No group session key found.'); } - const { iv, ciphertext } = await Aes.encrypt(data, this.groupSessionKey); + const { iv, ciphertext } = await Aes.encrypt(this.groupSessionKey, data); const encryptedData = { kid: this.keyID, iv: Base64.encode(iv), @@ -707,7 +706,7 @@ export class E2ERoom extends Emitter { span.set('type', key.type); span.set('usages', key.usages.toString()); try { - const result = await Aes.decrypt({ iv, ciphertext }, key); + const result = await Aes.decrypt(key, { iv, ciphertext }); const ret: unknown = EJSON.parse(result); if (typeof ret !== 'object' || ret === null) { span.error('Decrypted message is not an object'); From 9a381a2dc821a92774be591876219bc550676d73 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 234/251] test: binary codec --- apps/meteor/client/lib/e2ee/binary.spec.ts | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/meteor/client/lib/e2ee/binary.spec.ts diff --git a/apps/meteor/client/lib/e2ee/binary.spec.ts b/apps/meteor/client/lib/e2ee/binary.spec.ts new file mode 100644 index 0000000000000..580895ce7d264 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/binary.spec.ts @@ -0,0 +1,37 @@ +import { Binary } from './binary'; + +describe('Binary', () => { + describe('toString', () => { + it('should convert ArrayBuffer to string', () => { + const array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = Binary.toString(array.buffer); + expect(result).toBe('Hello'); + }); + + it('should handle empty ArrayBuffer', () => { + const buffer = new ArrayBuffer(0); + const result = Binary.toString(buffer); + expect(result).toBe(''); + }); + }); + + describe('toArrayBuffer', () => { + it('should convert string to ArrayBuffer', () => { + const str = 'Hello'; + const buffer = Binary.toArrayBuffer(str); + const uint8 = new Uint8Array(buffer); + expect(Array.from(uint8)).toEqual([72, 101, 108, 108, 111]); + }); + + it('should handle empty string', () => { + const str = ''; + const buffer = Binary.toArrayBuffer(str); + expect(buffer.byteLength).toBe(0); + }); + + it('should throw RangeError for illegal char code', () => { + const str = 'Hello\u0100'; // Character with char code 256 + expect(() => Binary.toArrayBuffer(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`); + }); + }); +}); From f055eec079d0ce3d4b0e06072eb3aa0dda9fa802 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Oct 2025 12:43:16 -0300 Subject: [PATCH 235/251] chore: runtime and typings cleanups --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- .../client/views/root/hooks/loggedIn/useE2EEncryption.ts | 2 +- packages/core-typings/src/IUpload.ts | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 96faa64c4dff1..c7d87f54c2dd1 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -211,7 +211,7 @@ class E2E extends Emitter { await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { - span.error('invalidE2ESuggestedKey', sub.E2ESuggestedKey); + span.error('invalidE2ESuggestedKey'); await e2e.rejectSuggestedKey(sub.rid); } diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts index 74ac41ff85e0e..695af7c53f83b 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts @@ -21,7 +21,7 @@ export const useE2EEncryption = () => { return; } - if (window.crypto && enabled && !adminEmbedded) { + if (enabled && !adminEmbedded) { e2e.startClient(userId); } else { e2e.setState('DISABLED'); diff --git a/packages/core-typings/src/IUpload.ts b/packages/core-typings/src/IUpload.ts index e27817d620996..6918bff625d21 100644 --- a/packages/core-typings/src/IUpload.ts +++ b/packages/core-typings/src/IUpload.ts @@ -65,10 +65,12 @@ export interface IUpload { }; } -export type IUploadWithUser = IUpload & { user?: Pick }; +export interface IUploadWithUser extends IUpload { + user?: Pick; +} -export type IE2EEUpload = IUpload & { +export interface IE2EEUpload extends IUpload { content: EncryptedContent; -}; +} export const isE2EEUpload = (upload: IUpload): upload is IE2EEUpload => Boolean(upload?.content?.ciphertext && upload?.content?.algorithm); From 2c4410f4080c8bf061167651da76a8831ef47ed0 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 20 Oct 2025 10:34:19 -0300 Subject: [PATCH 236/251] fix: federation conflict --- .../core-typings/src/IMessage/IMessage.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index e8a05dd0b5fb5..7ec5cd74943ac 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -135,17 +135,29 @@ export type MessageMention = { export interface IMessageCustomFields {} -export type EncryptedContent = - | { - algorithm: 'rc.v1.aes-sha2'; - ciphertext: string; - } - | { - algorithm: 'rc.v2.aes-sha2'; - ciphertext: string; - iv: string; // Initialization Vector - kid: string; // ID of the key used to encrypt the message - }; +interface IEncryptedContent { + algorithm: string; + ciphertext: string; +} + +interface IEncryptedContentV1 extends IEncryptedContent { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; +} + +interface IEncryptedContentV2 extends IEncryptedContent { + algorithm: 'rc.v2.aes-sha2'; + ciphertext: string; + iv: string; // Initialization Vector + kid: string; // ID of the key used to encrypt the message +} + +interface IEncryptedContentFederation extends IEncryptedContent { + algorithm: 'm.megolm.v1.aes-sha2'; + ciphertext: string; +} + +export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2 | IEncryptedContentFederation; export interface IMessage extends IRocketChatRecord { rid: RoomID; @@ -247,9 +259,7 @@ export interface IMessage extends IRocketChatRecord { content?: EncryptedContent; } -export type EncryptedMessageContent = { - content: EncryptedContent; -}; +export type EncryptedMessageContent = Required>; export function isEncryptedMessageContent(value: unknown): value is EncryptedMessageContent { return ( @@ -296,7 +306,7 @@ export const isDeletedMessage = (message: IMessage): message is IEditedMessage = export interface IFederatedMessage extends IMessage { federation: { eventId: string; - }; + } } export interface INativeFederatedMessage extends IMessage { From ea6bc1765a89d1e81ce3f51980e5261c9217b464 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 20 Oct 2025 10:39:23 -0300 Subject: [PATCH 237/251] fix: formatting --- packages/core-typings/src/IMessage/IMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 7ec5cd74943ac..d0442adc148d4 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -306,7 +306,7 @@ export const isDeletedMessage = (message: IMessage): message is IEditedMessage = export interface IFederatedMessage extends IMessage { federation: { eventId: string; - } + }; } export interface INativeFederatedMessage extends IMessage { From e90e176061665f30147f47ecef305a71299a6dec Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 20 Oct 2025 12:04:24 -0300 Subject: [PATCH 238/251] fix: typings --- apps/meteor/server/services/messages/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index d48b97c040e02..4c8a39794f966 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -101,7 +101,7 @@ export class MessageService extends ServiceClassInternal implements IMessageServ federation_event_id: string; msg?: string; e2e_content?: { - algorithm: string; + algorithm: 'm.megolm.v1.aes-sha2'; ciphertext: string; }; file?: IMessage['file']; From d034005091d282c029634a6399e1a8af45346027 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 20 Oct 2025 13:48:53 -0300 Subject: [PATCH 239/251] chore: add aes tests --- apps/meteor/client/lib/e2ee/aes.spec.ts | 101 ++++++++ apps/meteor/client/lib/e2ee/aes.ts | 34 ++- apps/meteor/client/lib/e2ee/content.spec.ts | 257 ++++++++++---------- 3 files changed, 258 insertions(+), 134 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/aes.spec.ts diff --git a/apps/meteor/client/lib/e2ee/aes.spec.ts b/apps/meteor/client/lib/e2ee/aes.spec.ts new file mode 100644 index 0000000000000..21a7e362067bd --- /dev/null +++ b/apps/meteor/client/lib/e2ee/aes.spec.ts @@ -0,0 +1,101 @@ +import { webcrypto } from 'node:crypto'; + +import { generateKey, decrypt, encrypt, exportKey, importKey, type Key } from './aes'; + +Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); + +describe('aes', () => { + describe('256-gcm', () => { + let key: Key<{ name: 'AES-GCM'; length: 256 }>; + + beforeAll(async () => { + key = await generateKey(); + }); + + it('generate a key with correct properties', async () => { + expect<256>(key.algorithm.length).toBe(256); + expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, world!'); + const ciphertext = await encrypt(key, plaintext); + const decrypted = await decrypt(key, ciphertext); + expect(decrypted).toBe('Hello, world!'); + }); + + it('should export and re-import the key correctly', async () => { + const jwk = await exportKey(key); + const importedKey = await importKey(jwk); + expect<256>(importedKey.algorithm.length).toBe(256); + expect<'AES-GCM'>(importedKey.algorithm.name).toBe('AES-GCM'); + expect(importedKey.extractable).toBe(true); + expect<'secret'>(importedKey.type).toBe('secret'); + expect<2>(importedKey.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']); + }); + }); + + describe('128-cbc', () => { + let key: Key<{ name: 'AES-CBC'; length: 128 }>; + + beforeAll(async () => { + key = await importKey({ alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_ops: ['encrypt', 'decrypt'], kty: 'oct' }); + }); + + it('import a key with correct properties', async () => { + expect<128>(key.algorithm.length).toBe(128); + expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(['encrypt', 'decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, AES-CBC!'); + const ciphertext = await encrypt(key, plaintext); + const decrypted = await decrypt(key, ciphertext); + expect(decrypted).toBe('Hello, AES-CBC!'); + }); + + it('should export and re-import the key correctly', async () => { + const jwk = await exportKey(key); + const importedKey = await importKey(jwk); + expect<128>(importedKey.algorithm.length).toBe(128); + expect<'AES-CBC'>(importedKey.algorithm.name).toBe('AES-CBC'); + expect(importedKey.extractable).toBe(true); + expect<'secret'>(importedKey.type).toBe('secret'); + expect<2>(importedKey.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(importedKey.usages).toEqual(['encrypt', 'decrypt']); + }); + }); + + it('should throw on unsupported JWK alg', async () => { + await expect( + importKey({ + // @ts-expect-error testing invalid alg + alg: 'A128GCM', + ext: true, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }), + ).rejects.toThrow('Unsupported JWK alg: A128GCM'); + + await expect( + importKey({ + // @ts-expect-error testing invalid alg + alg: 'A256CBC', + ext: true, + k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }), + ).rejects.toThrow('Unsupported JWK alg: A256CBC'); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/aes.ts b/apps/meteor/client/lib/e2ee/aes.ts index 0a5b9802c3fa4..e6087a7553557 100644 --- a/apps/meteor/client/lib/e2ee/aes.ts +++ b/apps/meteor/client/lib/e2ee/aes.ts @@ -10,8 +10,19 @@ interface IAesGcmKeyAlgorithm extends Aes length: TLength; } -export type Key = CryptoKey & { - algorithm: IAesCbcKeyAlgorithm<128> | IAesGcmKeyAlgorithm<256>; +type SupportedAesKeyAlgorithm = IAesCbcKeyAlgorithm<128> | IAesGcmKeyAlgorithm<256>; + +type JwkAlg = 'A128CBC' | 'A256GCM'; +type Jwk = { + kty: 'oct'; + k: string; + key_ops: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; + ext: true; + alg: TAlg; +}; + +export type Key = CryptoKey & { + algorithm: TAlgorithm; extractable: true; type: 'secret'; usages: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; @@ -22,12 +33,15 @@ type AesEncryptedContent = { ciphertext: Uint8Array; }; +type ToJwkAlg = + TAlgorithm extends IAesGcmKeyAlgorithm<256> ? 'A256GCM' : TAlgorithm extends IAesCbcKeyAlgorithm<128> ? 'A128CBC' : never; + const JWK_ALG_TO_AES_KEY_ALGORITHM = { A256GCM: { name: 'AES-GCM', length: 256 }, A128CBC: { name: 'AES-CBC', length: 128 }, -} satisfies Record; +} satisfies Record; -export const importKey = async (jwk: JsonWebKey): Promise => { +export const importKey = async (jwk: TJwk): Promise> => { if (!('alg' in jwk)) { throw new Error('JWK alg property is required to import the key'); } @@ -40,17 +54,17 @@ export const importKey = async (jwk: JsonWebKey): Promise => { const key = await crypto.subtle.importKey('jwk', jwk, algorithm, true, ['encrypt', 'decrypt']); - return key as Key; + return key as Key<(typeof JWK_ALG_TO_AES_KEY_ALGORITHM)[TJwk['alg']]>; }; -export const exportKey = (key: Key): Promise => { - return crypto.subtle.exportKey('jwk', key); +export const exportKey = async (key: Key): Promise>> => { + const jwk = await crypto.subtle.exportKey('jwk', key); + return jwk as Jwk>; }; -export const generateKey = async (): Promise => { +export const generateKey = async (): Promise> => { const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - - return key as Key; + return key as Key<{ name: 'AES-GCM'; length: 256 }>; }; export const decrypt = async (key: Key, content: AesEncryptedContent): Promise => { diff --git a/apps/meteor/client/lib/e2ee/content.spec.ts b/apps/meteor/client/lib/e2ee/content.spec.ts index 1352ded8638d0..c326af8c759e9 100644 --- a/apps/meteor/client/lib/e2ee/content.spec.ts +++ b/apps/meteor/client/lib/e2ee/content.spec.ts @@ -1,139 +1,148 @@ import { webcrypto } from 'node:crypto'; -import { importKey, decrypt } from './aes'; +import { importKey, decrypt, type Key } from './aes'; import { decodeEncryptedContent } from './content'; Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); -const msgv1web = Object.freeze({ - _id: 'JfAxN6Ncsw2XS9eiY', - rid: '68dad82a10815056615446aa', - e2eMentions: { - e2eUserMentions: [], - e2eChannelMentions: [], - }, - content: Object.freeze({ - algorithm: 'rc.v1.aes-sha2', - ciphertext: '32c9e7917b78LHjHfqLMeDn+2UK1PhD/soFe8CVwvFdLkslcfxNHby4=', - }), - t: 'e2e', - ts: { - $date: '2025-09-29T19:07:07.908Z', - }, - u: { - _id: 'bm4cAAcN92jgXe2jN', - username: 'alice', - name: 'alice', - }, - msg: '', - _updatedAt: { - $date: '2025-09-29T19:07:07.929Z', - }, - urls: [], - mentions: [], - channels: [], -}); +describe('content', () => { + const msgv1web = Object.freeze({ + _id: 'JfAxN6Ncsw2XS9eiY', + rid: '68dad82a10815056615446aa', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: '32c9e7917b78LHjHfqLMeDn+2UK1PhD/soFe8CVwvFdLkslcfxNHby4=', + }), + t: 'e2e', + ts: { + $date: '2025-09-29T19:07:07.908Z', + }, + u: { + _id: 'bm4cAAcN92jgXe2jN', + username: 'alice', + name: 'alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-29T19:07:07.929Z', + }, + urls: [], + mentions: [], + channels: [], + }); -const msgv1mob = Object.freeze({ - _id: 'AZF8Myj605B3f7ZPL', - rid: '68dad82a10815056615446aa', - msg: '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', - t: 'e2e', - e2e: 'pending', - e2eMentions: { - e2eUserMentions: [], - e2eChannelMentions: [], - }, - content: Object.freeze({ - algorithm: 'rc.v1.aes-sha2', - ciphertext: - '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', - }), - ts: { - $date: '2025-09-29T19:28:35.261Z', - }, - u: { - _id: 'RQTYT5RJoDKZFwDhk', - username: 'bob', - name: 'bob', - }, - _updatedAt: { - $date: '2025-09-29T19:28:35.274Z', - }, - urls: [], - mentions: [], - channels: [], -}); + const msgv1mob = Object.freeze({ + _id: 'AZF8Myj605B3f7ZPL', + rid: '68dad82a10815056615446aa', + msg: '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + t: 'e2e', + e2e: 'pending', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v1.aes-sha2', + ciphertext: + '32c9e7917b783JpM8aOVludqIRzx+DOqjEU9Mj3NUWb+/GLRl7sdkvTtCMChH1LBjMjJJvVJ6Rlw4dI8BYFftZWiCOiR7TPwriCoSPiZ7dY5C4H2q8MVSdR95ZiyG7eWQ5j5/rxzAYsSWDA9LkumW8JBb+WQ1hD9JMfQd4IXtlFMnaDgEhZhe/s=', + }), + ts: { + $date: '2025-09-29T19:28:35.261Z', + }, + u: { + _id: 'RQTYT5RJoDKZFwDhk', + username: 'bob', + name: 'bob', + }, + _updatedAt: { + $date: '2025-09-29T19:28:35.274Z', + }, + urls: [], + mentions: [], + channels: [], + }); -const aeskeyv1 = { alg: 'A128CBC', ext: true, k: 'qb8In0Rpa9nwSusvxxDcbQ', key_ops: ['encrypt', 'decrypt'], kty: 'oct' }; + describe('v1 messages', () => { + let key: Key<{ name: 'AES-CBC'; length: 128 }>; -test('parse v1 web message', async () => { - const parsed = decodeEncryptedContent(msgv1web.content); - const key = await importKey(aeskeyv1); - const decrypted = await decrypt(key, parsed); - expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); -}); + beforeAll(async () => { + key = await importKey({ + alg: 'A128CBC', + ext: true, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }); + }); -test('parse v1 mobile message', async () => { - const parsed = decodeEncryptedContent(msgv1mob.content); - const key = await importKey(aeskeyv1); - const decrypted = await decrypt(key, parsed); - expect(decrypted).toMatchInlineSnapshot( - `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, - ); -}); + test('parse v1 web message', async () => { + const parsed = decodeEncryptedContent(msgv1web.content); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); + }); -test('parse v1 mobile message from msg field', async () => { - const parsed = decodeEncryptedContent(msgv1mob.msg); - const key = await importKey(aeskeyv1); - const decrypted = await decrypt(key, parsed); - expect(decrypted).toMatchInlineSnapshot( - `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, - ); -}); + test('parse v1 mobile message', async () => { + const parsed = decodeEncryptedContent(msgv1mob.content); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); + }); -const msgv2web = Object.freeze({ - _id: 'h6sXWTiKcWfcgkhgo', - rid: '68c9da1b0427bc33b429207e', - e2eMentions: { - e2eUserMentions: [], - e2eChannelMentions: [], - }, - content: Object.freeze({ - algorithm: 'rc.v2.aes-sha2', - kid: 'f46d2864-0384-4a87-8815-51fba2cad216', - iv: 'wXbYQ8q9sYRCHtNp', - ciphertext: 'cIDO9mXzCCrrl/wORP0Jf6oWeusqzSCXVGGvY7CHrA==', - }), - t: 'e2e', - ts: { - $date: '2025-09-30T18:05:30.876Z', - }, - u: { - _id: 'Ctk47kkuzJihnmvZE', - username: 'alice', - name: 'Alice', - }, - msg: '', - _updatedAt: { - $date: '2025-09-30T18:05:30.887Z', - }, - urls: [], - mentions: [], - channels: [], -}); + test('parse v1 mobile message from msg field', async () => { + const parsed = decodeEncryptedContent(msgv1mob.msg); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot( + `"{"_id":"AZF8Myj605B3f7ZPL","text":"world","userId":"RQTYT5RJoDKZFwDhk","ts":{"$date":1759174115076}}"`, + ); + }); + }); -const aeskeyv2 = { - alg: 'A256GCM', - ext: true, - k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', - key_ops: ['encrypt', 'decrypt'], - kty: 'oct', -}; + const msgv2web = Object.freeze({ + _id: 'h6sXWTiKcWfcgkhgo', + rid: '68c9da1b0427bc33b429207e', + e2eMentions: { + e2eUserMentions: [], + e2eChannelMentions: [], + }, + content: Object.freeze({ + algorithm: 'rc.v2.aes-sha2', + kid: 'f46d2864-0384-4a87-8815-51fba2cad216', + iv: 'wXbYQ8q9sYRCHtNp', + ciphertext: 'cIDO9mXzCCrrl/wORP0Jf6oWeusqzSCXVGGvY7CHrA==', + }), + t: 'e2e', + ts: { + $date: '2025-09-30T18:05:30.876Z', + }, + u: { + _id: 'Ctk47kkuzJihnmvZE', + username: 'alice', + name: 'Alice', + }, + msg: '', + _updatedAt: { + $date: '2025-09-30T18:05:30.887Z', + }, + urls: [], + mentions: [], + channels: [], + }); -test('parse v2 web message', async () => { - const parsed = decodeEncryptedContent(msgv2web.content); - const key = await importKey(aeskeyv2); - const decrypted = await decrypt(key, parsed); - expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); + test('parse v2 web message', async () => { + const parsed = decodeEncryptedContent(msgv2web.content); + const key = await importKey({ + alg: 'A256GCM', + ext: true, + k: '9o1xoHt4OamRJvnaLna-5akUb5L98S_iWYGGaXPZ1Yg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct', + }); + const decrypted = await decrypt(key, parsed); + expect(decrypted).toMatchInlineSnapshot(`"{"msg":"hello"}"`); + }); }); From e07ccdb43d3f90560caf16ff429c987d976c48d6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 21 Oct 2025 16:04:44 -0300 Subject: [PATCH 240/251] refactor: use ICodec interface (encode/decode) and update tests --- apps/meteor/client/lib/e2ee/binary.spec.ts | 10 +++++----- apps/meteor/client/lib/e2ee/binary.ts | 8 +++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/binary.spec.ts b/apps/meteor/client/lib/e2ee/binary.spec.ts index 580895ce7d264..0b4c0ef668fdb 100644 --- a/apps/meteor/client/lib/e2ee/binary.spec.ts +++ b/apps/meteor/client/lib/e2ee/binary.spec.ts @@ -4,13 +4,13 @@ describe('Binary', () => { describe('toString', () => { it('should convert ArrayBuffer to string', () => { const array = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" - const result = Binary.toString(array.buffer); + const result = Binary.encode(array.buffer); expect(result).toBe('Hello'); }); it('should handle empty ArrayBuffer', () => { const buffer = new ArrayBuffer(0); - const result = Binary.toString(buffer); + const result = Binary.encode(buffer); expect(result).toBe(''); }); }); @@ -18,20 +18,20 @@ describe('Binary', () => { describe('toArrayBuffer', () => { it('should convert string to ArrayBuffer', () => { const str = 'Hello'; - const buffer = Binary.toArrayBuffer(str); + const buffer = Binary.decode(str); const uint8 = new Uint8Array(buffer); expect(Array.from(uint8)).toEqual([72, 101, 108, 108, 111]); }); it('should handle empty string', () => { const str = ''; - const buffer = Binary.toArrayBuffer(str); + const buffer = Binary.decode(str); expect(buffer.byteLength).toBe(0); }); it('should throw RangeError for illegal char code', () => { const str = 'Hello\u0100'; // Character with char code 256 - expect(() => Binary.toArrayBuffer(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`); + expect(() => Binary.decode(str)).toThrowErrorMatchingInlineSnapshot(`"illegal char code: 256"`); }); }); }); diff --git a/apps/meteor/client/lib/e2ee/binary.ts b/apps/meteor/client/lib/e2ee/binary.ts index fd3845ec9983b..00dbdeab7c867 100644 --- a/apps/meteor/client/lib/e2ee/binary.ts +++ b/apps/meteor/client/lib/e2ee/binary.ts @@ -1,5 +1,7 @@ -export const Binary = { - toString(buffer: ArrayBuffer): string { +import type { ICodec } from './codec'; + +export const Binary: ICodec = { + encode(buffer: ArrayBuffer): string { const uint8 = new Uint8Array(buffer); const CHUNK_SIZE = 8192; // Process in chunks for performance let result = ''; @@ -9,7 +11,7 @@ export const Binary = { } return result; }, - toArrayBuffer(str: string): ArrayBuffer { + decode(str: string): ArrayBuffer { // Create a Uint8Array of the same length as the string. // This will be a view on the new ArrayBuffer. const buffer = new ArrayBuffer(str.length); From d985a32764c90b8464795c78b9bfac40e25b5344 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 21 Oct 2025 16:04:45 -0300 Subject: [PATCH 241/251] refactor: remove ByteBuffer-based helpers and file-reader helper --- apps/meteor/client/lib/e2ee/helper.ts | 40 --------------------------- 1 file changed, 40 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/helper.ts b/apps/meteor/client/lib/e2ee/helper.ts index 285725fef92ff..83ae663e233f4 100644 --- a/apps/meteor/client/lib/e2ee/helper.ts +++ b/apps/meteor/client/lib/e2ee/helper.ts @@ -1,30 +1,3 @@ -import ByteBuffer from 'bytebuffer'; - -export function toString(thing: any) { - if (typeof thing === 'string') { - return thing; - } - - return ByteBuffer.wrap(thing).toString('binary'); -} - -export function toArrayBuffer(thing: any) { - if (thing === undefined) { - return undefined; - } - if (typeof thing === 'object') { - if (Object.getPrototypeOf(thing) === ArrayBuffer.prototype) { - return thing; - } - } - - if (typeof thing !== 'string') { - throw new Error(`Tried to convert a non-string of type ${typeof thing} to an array buffer`); - } - - return ByteBuffer.wrap(thing, 'binary').toArrayBuffer(); -} - export async function encryptAESCTR(counter: BufferSource, key: CryptoKey, data: BufferSource) { return crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data); } @@ -33,19 +6,6 @@ export function generateAESCTRKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-CTR', length: 256 }, true, ['encrypt', 'decrypt']); } -export function readFileAsArrayBuffer(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (evt) => { - resolve(evt.target?.result as ArrayBuffer); - }; - reader.onerror = (evt) => { - reject(evt); - }; - reader.readAsArrayBuffer(file); - }); -} - /** * Generates 12 uniformly random words from the word list. * From 9b0644b2f5128e9af1edae7e0a8254f1b29208eb Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 21 Oct 2025 16:04:45 -0300 Subject: [PATCH 242/251] refactor: use Binary.encode/decode and replace removed helpers --- apps/meteor/client/lib/e2ee/keychain.ts | 13 ++++++------ .../client/lib/e2ee/rocketchat.e2e.room.ts | 21 +++++++------------ 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index ea76780e2aa93..8d84f78afaf71 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -18,7 +18,6 @@ interface IStoredKeyV1 { */ $binary: string; } - /** * Version 2 format: * ```typescript @@ -139,24 +138,24 @@ export class Keychain { async decryptKey(privateKey: string, password: string): Promise { const { content, options } = this.codec.decode(privateKey); const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM'; - const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); const derivedBits = await Pbkdf2.deriveBits(baseKey, { - salt: new Uint8Array(Binary.toArrayBuffer(options.salt)), + salt: new Uint8Array(Binary.decode(options.salt)), iterations: options.iterations, }); const key = await Pbkdf2.importKey(derivedBits, algorithm); const decrypted = await Pbkdf2.decrypt(key, content); - return Binary.toString(decrypted.buffer); + return Binary.encode(decrypted.buffer); } async encryptKey(privateKey: string, password: string): Promise { const salt = `v2:${this.userId}:${crypto.randomUUID()}`; const iterations = 100_000; const algorithm = 'AES-GCM'; - const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.toArrayBuffer(password))); - const derivedBits = await Pbkdf2.deriveBits(baseKey, { salt: new Uint8Array(Binary.toArrayBuffer(salt)), iterations }); + const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); + const derivedBits = await Pbkdf2.deriveBits(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations }); const key = await Pbkdf2.importKey(derivedBits, algorithm); - const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.toArrayBuffer(privateKey))); + const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.decode(privateKey))); return this.codec.encode({ content, options: { salt, iterations } }); } diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index f85f29b253d3e..02bc04a400554 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -18,14 +18,7 @@ import type { E2ERoomState } from './E2ERoomState'; import * as Aes from './aes'; import { Binary } from './binary'; import { decodeEncryptedContent } from './content'; -import { - toArrayBuffer, - readFileAsArrayBuffer, - encryptAESCTR, - generateAESCTRKey, - sha256HashFromArrayBuffer, - createSha256HashFromText, -} from './helper'; +import { encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText } from './helper'; import { createLogger } from './logger'; import { PrefixedBase64 } from './prefixed'; import { e2e } from './rocketchat.e2e'; @@ -374,7 +367,7 @@ export class E2ERoom extends Emitter { const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); span.info('Key decrypted'); - return Binary.toString(decryptedKey.buffer); + return Binary.encode(decryptedKey.buffer); } async importGroupKey(groupKey: string) { @@ -394,7 +387,7 @@ export class E2ERoom extends Emitter { throw new Error('Private key not found'); } const decryptedKey = await Rsa.decrypt(e2e.privateKey, decodedKey); - this.sessionKeyExportedString = Binary.toString(decryptedKey.buffer); + this.sessionKeyExportedString = Binary.encode(decryptedKey.buffer); } catch (error) { span.set('error', error).error('Error decrypting group key'); return false; @@ -547,7 +540,7 @@ export class E2ERoom extends Emitter { if (!oldRoomKey.E2EKey) { continue; } - const encryptedKey = await Rsa.encrypt(userKey, Binary.toArrayBuffer(oldRoomKey.E2EKey)); + const encryptedKey = await Rsa.encrypt(userKey, Binary.decode(oldRoomKey.E2EKey)); const encryptedKeyToString = PrefixedBase64.encode([oldRoomKey.e2eKeyId, encryptedKey]); keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); @@ -569,7 +562,7 @@ export class E2ERoom extends Emitter { // Encrypt session key for this user with his/her public key try { - const encryptedUserKey = await Rsa.encrypt(userKey, Binary.toArrayBuffer(this.sessionKeyExportedString!)); + const encryptedUserKey = await Rsa.encrypt(userKey, Binary.decode(this.sessionKeyExportedString!)); const encryptedUserKeyToString = PrefixedBase64.encode([this.keyID, encryptedUserKey]); span.info('Group key encrypted for participant'); return encryptedUserKeyToString; @@ -582,7 +575,7 @@ export class E2ERoom extends Emitter { async encryptFile(file: File) { const span = log.span('encryptFile'); - const fileArrayBuffer = await readFileAsArrayBuffer(file); + const fileArrayBuffer = await file.arrayBuffer(); const hash = await sha256HashFromArrayBuffer(fileArrayBuffer); @@ -599,7 +592,7 @@ export class E2ERoom extends Emitter { const fileName = await createSha256HashFromText(file.name); - const encryptedFile = new File([toArrayBuffer(result)], fileName); + const encryptedFile = new File([result], fileName); return { file: encryptedFile, From ec7b32bc34ad3ce33e9c2585871c4aa6a1ad71c8 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 21 Oct 2025 16:04:45 -0300 Subject: [PATCH 243/251] refactor: E2EE class behaviors, key handling, and key export/import --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index c7d87f54c2dd1..1b7a9693cf571 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -45,13 +45,15 @@ const ROOM_KEY_EXCHANGE_SIZE = 10; class E2E extends Emitter { private userId: string | false = false; + private keychain: Keychain; + private instancesByRoomId: Record = {}; private db_public_key: string | null | undefined; private db_private_key: string | null | undefined; - public privateKey: Rsa.PrivateKey | undefined; + public privateKey: Rsa.IPrivateKey | undefined; public publicKey: string | undefined; @@ -129,7 +131,11 @@ class E2E extends Emitter { } } - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + if (sub.encrypted) { + e2eRoom.resume(); + } else { + e2eRoom.pause(); + } // Cover private groups and direct messages if (!e2eRoom.isSupportedRoomType(sub.t)) { @@ -175,11 +181,6 @@ class E2E extends Emitter { }); } - shouldAskForE2EEPassword() { - const { private_key } = this.getKeysFromLocalStorage(); - return this.db_private_key && !private_key; - } - setState(nextState: E2EEState) { const span = log.span('setState').set('prevState', this.state).set('nextState', nextState); const prevState = this.state; @@ -215,7 +216,11 @@ class E2E extends Emitter { await e2e.rejectSuggestedKey(sub.rid); } - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + if (sub.encrypted) { + e2eRoom.resume(); + } else { + e2eRoom.pause(); + } }), ); span.info('handledAsyncE2ESuggestedKey'); @@ -282,9 +287,7 @@ class E2E extends Emitter { throw new Error('Failed to persist keys as they are not strings.'); } - const keychain = new Keychain(this.getUserId()); - - const encodedPrivateKey = await keychain.encryptKey(private_key, password); + const encodedPrivateKey = await this.keychain.encryptKey(private_key, password); if (!encodedPrivateKey) { throw new Error('Failed to encode private key with provided password.'); @@ -350,6 +353,7 @@ class E2E extends Emitter { span.info(this.state); this.userId = userId; + this.keychain = new Keychain(userId); let { public_key, private_key } = this.getKeysFromLocalStorage(); @@ -359,10 +363,10 @@ class E2E extends Emitter { public_key = this.db_public_key; } - if (this.shouldAskForE2EEPassword()) { + if (this.db_private_key && !private_key) { try { this.setState('ENTER_PASSWORD'); - private_key = await this.decodePrivateKey(this.db_private_key!); + private_key = await this.decodePrivateKey(this.db_private_key); } catch (error) { this.userId = false; failedToDecodeKey = true; @@ -419,7 +423,9 @@ class E2E extends Emitter { this.privateKey = undefined; this.publicKey = undefined; this.userId = false; - this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); + if (this.keyDistributionInterval) { + clearInterval(this.keyDistributionInterval); + } this.keyDistributionInterval = null; this.setState('DISABLED'); } @@ -480,7 +486,7 @@ class E2E extends Emitter { } try { - const publicKey = await Rsa.exportKey(keyPair.publicKey); + const publicKey = await Rsa.exportPublicKey(keyPair.publicKey); this.publicKey = JSON.stringify(publicKey); Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); @@ -490,7 +496,7 @@ class E2E extends Emitter { } try { - const privateKey = await Rsa.exportKey(keyPair.privateKey); + const privateKey = await Rsa.exportPrivateKey(keyPair.privateKey); Accounts.storageLocation.setItem('private_key', JSON.stringify(privateKey)); } catch (error) { @@ -567,10 +573,8 @@ class E2E extends Emitter { return; } - const keychain = new Keychain(this.getUserId()); - try { - const privateKey = await keychain.decryptKey(this.db_private_key, password); + const privateKey = await this.keychain.decryptKey(this.db_private_key, password); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); @@ -591,9 +595,8 @@ class E2E extends Emitter { async decodePrivateKey(privateKey: string): Promise { // const span = log.span('decodePrivateKey'); const password = await this.requestPasswordAlert(); - const keychain = new Keychain(this.getUserId()); try { - const privKey = await keychain.decryptKey(privateKey, password); + const privKey = await this.keychain.decryptKey(privateKey, password); return privKey; } catch (error) { this.setState('ENTER_PASSWORD'); @@ -847,7 +850,7 @@ class E2E extends Emitter { try { await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); } catch (error) { - return span.set('error', error).error('provideUsersSuggestedGroupKeys'); + return span.error('provideUsersSuggestedGroupKeys', error); } }; From 6e4354edd27291a055e3270d84358848749f34ec Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 21 Oct 2025 16:04:45 -0300 Subject: [PATCH 244/251] refactor: RSA module types and JWK import/export, and add RSA tests --- apps/meteor/client/lib/e2ee/rsa.spec.ts | 76 +++++++++++++++++++++ apps/meteor/client/lib/e2ee/rsa.ts | 89 ++++++++++++++++++++----- 2 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 apps/meteor/client/lib/e2ee/rsa.spec.ts diff --git a/apps/meteor/client/lib/e2ee/rsa.spec.ts b/apps/meteor/client/lib/e2ee/rsa.spec.ts new file mode 100644 index 0000000000000..3285db50275c4 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/rsa.spec.ts @@ -0,0 +1,76 @@ +import { webcrypto } from 'node:crypto'; + +import { + generateKeyPair, + decrypt, + encrypt, + exportPublicKey, + exportPrivateKey, + importPrivateKey, + importPublicKey, + type IKeyPair, +} from './rsa'; + +Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); + +describe('rsa', () => { + let keyPair: IKeyPair; + + beforeAll(async () => { + keyPair = await generateKeyPair(); + }); + + it('generate a key pair with correct properties', async () => { + expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.publicKey.extractable).toBe(true); + expect<'public'>(keyPair.publicKey.type).toBe('public'); + expect<1>(keyPair.publicKey.usages.length).toBe(1); + expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']); + + expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.privateKey.extractable).toBe(true); + expect<'private'>(keyPair.privateKey.type).toBe('private'); + expect<1>(keyPair.privateKey.usages.length).toBe(1); + expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']); + }); + + it('should encrypt and decrypt data correctly', async () => { + const plaintext = new TextEncoder().encode('Hello, RSA-OAEP!'); + const ciphertext = await encrypt(keyPair.publicKey, plaintext); + const decrypted = await decrypt(keyPair.privateKey, ciphertext); + expect(new TextDecoder().decode(decrypted)).toBe('Hello, RSA-OAEP!'); + }); + + it('should export and re-import the keys correctly', async () => { + const publicJwk = await exportPublicKey(keyPair.publicKey); + const privateJwk = await exportPrivateKey(keyPair.privateKey); + + expect<'RSA-OAEP-256'>(publicJwk.alg).toBe('RSA-OAEP-256'); + expect<'RSA'>(publicJwk.kty).toBe('RSA'); + expect(publicJwk.ext).toBe(true); + expect<'AQAB'>(publicJwk.e).toBe('AQAB'); + + expect<'RSA-OAEP-256'>(privateJwk.alg).toBe('RSA-OAEP-256'); + expect<'RSA'>(privateJwk.kty).toBe('RSA'); + expect(privateJwk.ext).toBe(true); + expect<'AQAB'>(privateJwk.e).toBe('AQAB'); + + expect(privateJwk.n).toEqual(publicJwk.n); + + const importedPublicKey = await importPublicKey(publicJwk); + expect<'RSA-OAEP'>(importedPublicKey.algorithm.name).toBe('RSA-OAEP'); + expect(importedPublicKey.extractable).toBe(true); + expect<'public'>(importedPublicKey.type).toBe('public'); + expect<1>(importedPublicKey.usages.length).toBe(1); + expect<['encrypt']>(importedPublicKey.usages).toEqual(['encrypt']); + + const importedPrivateKey = await importPrivateKey(privateJwk); + expect<'RSA-OAEP'>(importedPrivateKey.algorithm.name).toBe('RSA-OAEP'); + expect(importedPrivateKey.extractable).toBe(true); + expect<'private'>(importedPrivateKey.type).toBe('private'); + expect<1>(importedPrivateKey.usages.length).toBe(1); + expect<['decrypt']>(importedPrivateKey.usages).toEqual(['decrypt']); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/rsa.ts b/apps/meteor/client/lib/e2ee/rsa.ts index 7966c51eb4e78..5bdc803138ebb 100644 --- a/apps/meteor/client/lib/e2ee/rsa.ts +++ b/apps/meteor/client/lib/e2ee/rsa.ts @@ -1,28 +1,87 @@ -export type Key = CryptoKey & { algorithm: RsaKeyAlgorithm }; -export type PrivateKey = Key & { type: 'private'; usages: ['decrypt'] }; -export type PublicKey = Key & { type: 'public'; usages: ['encrypt'] }; -export type KeyPair = CryptoKeyPair & { publicKey: PublicKey; privateKey: PrivateKey }; +type HashAlgorithm = { name: 'SHA-256' }; +type ModulusLength = 2048; +type Name = 'RSA-OAEP'; -export const generateKeyPair = async (): Promise => { +interface IRsaAlgorithm extends RsaHashedKeyAlgorithm { + readonly name: Name; + readonly modulusLength: ModulusLength; + readonly publicExponent: Uint8Array; + readonly hash: HashAlgorithm; +} + +export interface IKey extends CryptoKey { + algorithm: IRsaAlgorithm; +} + +export interface IPrivateKey extends IKey { + type: 'private'; + usages: ['decrypt']; + extractable: true; +} + +export interface IPublicKey extends IKey { + type: 'public'; + usages: ['encrypt']; + extractable: true; +} + +export interface IKeyPair extends CryptoKeyPair { + publicKey: IPublicKey; + privateKey: IPrivateKey; +} + +export const generateKeyPair = async (): Promise => { const keyPair = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: { name: 'SHA-256' }, - } satisfies RsaHashedKeyGenParams, + } satisfies IRsaAlgorithm, true, ['encrypt', 'decrypt'], ); - return keyPair as KeyPair; + return keyPair as IKeyPair; +}; + +type Base64Url = string; + +export interface IPublicJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + key_ops: ['encrypt']; + n: Base64Url; +} + +export interface IPrivateJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + d: Base64Url; + dp: Base64Url; + dq: Base64Url; + key_ops: ['decrypt']; + n: Base64Url; + p: Base64Url; + q: Base64Url; + qi: Base64Url; +} + +export const exportPublicKey = async (key: IPublicKey): Promise => { + const jwk = await crypto.subtle.exportKey('jwk', key); + return jwk as IPublicJwk; }; -export const exportKey = (key: Key): Promise => { - return crypto.subtle.exportKey('jwk', key); +export const exportPrivateKey = async (key: IPrivateKey): Promise => { + const jwk = await crypto.subtle.exportKey('jwk', key); + return jwk as IPrivateJwk; }; -export const importPrivateKey = async (keyData: JsonWebKey): Promise => { +export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { const key = await crypto.subtle.importKey( 'jwk', keyData, @@ -33,10 +92,10 @@ export const importPrivateKey = async (keyData: JsonWebKey): Promise true, ['decrypt'], ); - return key as PrivateKey; + return key as IPrivateKey; }; -export const importPublicKey = async (keyData: JsonWebKey): Promise => { +export const importPublicKey = async (keyData: IPublicJwk): Promise => { const key = await crypto.subtle.importKey( 'jwk', keyData, @@ -47,15 +106,15 @@ export const importPublicKey = async (keyData: JsonWebKey): Promise = true, ['encrypt'], ); - return key as PublicKey; + return key as IPublicKey; }; -export const encrypt = async (key: PublicKey, data: BufferSource): Promise> => { +export const encrypt = async (key: IPublicKey, data: BufferSource): Promise> => { const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); return new Uint8Array(encrypted); }; -export const decrypt = async (key: PrivateKey, data: BufferSource): Promise> => { +export const decrypt = async (key: IPrivateKey, data: BufferSource): Promise> => { const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); return new Uint8Array(decrypted); }; From 44e85a5712dff3f18a86262789c23b3053233d89 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 22 Oct 2025 13:39:45 -0300 Subject: [PATCH 245/251] chore: move crypto primitives to own folder --- apps/meteor/client/lib/e2ee/content.spec.ts | 6 +- apps/meteor/client/lib/e2ee/content.ts | 2 +- .../client/lib/e2ee/{ => crypto}/aes.spec.ts | 8 +- .../client/lib/e2ee/{ => crypto}/aes.ts | 20 +-- .../lib/e2ee/{ => crypto}/pbkdf2.spec.ts | 4 - .../client/lib/e2ee/{ => crypto}/pbkdf2.ts | 14 +- .../client/lib/e2ee/{ => crypto}/rsa.spec.ts | 14 +- apps/meteor/client/lib/e2ee/crypto/rsa.ts | 107 +++++++++++++ .../client/lib/e2ee/crypto/shared.spec.ts | 131 +++++++++++++++ apps/meteor/client/lib/e2ee/crypto/shared.ts | 150 ++++++++++++++++++ apps/meteor/client/lib/e2ee/keychain.spec.ts | 4 - apps/meteor/client/lib/e2ee/keychain.ts | 5 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 8 +- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 20 +-- apps/meteor/client/lib/e2ee/rsa.ts | 120 -------------- 15 files changed, 428 insertions(+), 185 deletions(-) rename apps/meteor/client/lib/e2ee/{ => crypto}/aes.spec.ts (93%) rename apps/meteor/client/lib/e2ee/{ => crypto}/aes.ts (77%) rename apps/meteor/client/lib/e2ee/{ => crypto}/pbkdf2.spec.ts (95%) rename apps/meteor/client/lib/e2ee/{ => crypto}/pbkdf2.ts (82%) rename apps/meteor/client/lib/e2ee/{ => crypto}/rsa.spec.ts (92%) create mode 100644 apps/meteor/client/lib/e2ee/crypto/rsa.ts create mode 100644 apps/meteor/client/lib/e2ee/crypto/shared.spec.ts create mode 100644 apps/meteor/client/lib/e2ee/crypto/shared.ts delete mode 100644 apps/meteor/client/lib/e2ee/rsa.ts diff --git a/apps/meteor/client/lib/e2ee/content.spec.ts b/apps/meteor/client/lib/e2ee/content.spec.ts index c326af8c759e9..fc354f4690611 100644 --- a/apps/meteor/client/lib/e2ee/content.spec.ts +++ b/apps/meteor/client/lib/e2ee/content.spec.ts @@ -1,9 +1,5 @@ -import { webcrypto } from 'node:crypto'; - -import { importKey, decrypt, type Key } from './aes'; import { decodeEncryptedContent } from './content'; - -Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); +import { importKey, decrypt, type Key } from './crypto/aes'; describe('content', () => { const msgv1web = Object.freeze({ diff --git a/apps/meteor/client/lib/e2ee/content.ts b/apps/meteor/client/lib/e2ee/content.ts index b051434c7ee1e..07c38df821017 100644 --- a/apps/meteor/client/lib/e2ee/content.ts +++ b/apps/meteor/client/lib/e2ee/content.ts @@ -62,7 +62,7 @@ const decodeV2EncryptedContent = (payload: Extract { +const normalizePayload = (payload: string | EncryptedContent): EncryptedContent => { if (typeof payload === 'string') { return { algorithm: 'rc.v1.aes-sha2', ciphertext: payload } as const; } diff --git a/apps/meteor/client/lib/e2ee/aes.spec.ts b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts similarity index 93% rename from apps/meteor/client/lib/e2ee/aes.spec.ts rename to apps/meteor/client/lib/e2ee/crypto/aes.spec.ts index 21a7e362067bd..aaef2e81409cd 100644 --- a/apps/meteor/client/lib/e2ee/aes.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts @@ -1,15 +1,11 @@ -import { webcrypto } from 'node:crypto'; - -import { generateKey, decrypt, encrypt, exportKey, importKey, type Key } from './aes'; - -Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); +import { generate, decrypt, encrypt, exportKey, importKey, type Key } from './aes'; describe('aes', () => { describe('256-gcm', () => { let key: Key<{ name: 'AES-GCM'; length: 256 }>; beforeAll(async () => { - key = await generateKey(); + key = await generate(); }); it('generate a key with correct properties', async () => { diff --git a/apps/meteor/client/lib/e2ee/aes.ts b/apps/meteor/client/lib/e2ee/crypto/aes.ts similarity index 77% rename from apps/meteor/client/lib/e2ee/aes.ts rename to apps/meteor/client/lib/e2ee/crypto/aes.ts index e6087a7553557..ad263eb3c34d2 100644 --- a/apps/meteor/client/lib/e2ee/aes.ts +++ b/apps/meteor/client/lib/e2ee/crypto/aes.ts @@ -1,3 +1,5 @@ +import { subtle, getRandomValues, generateKey, type IKey } from './shared'; + type AesKeyAlgorithmLength = 128 | 256; interface IAesCbcKeyAlgorithm extends AesKeyAlgorithm { @@ -13,7 +15,7 @@ interface IAesGcmKeyAlgorithm extends Aes type SupportedAesKeyAlgorithm = IAesCbcKeyAlgorithm<128> | IAesGcmKeyAlgorithm<256>; type JwkAlg = 'A128CBC' | 'A256GCM'; -type Jwk = { +export type Jwk = { kty: 'oct'; k: string; key_ops: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; @@ -52,23 +54,23 @@ export const importKey = async (jwk: TJwk): Promise; }; export const exportKey = async (key: Key): Promise>> => { - const jwk = await crypto.subtle.exportKey('jwk', key); + const jwk = await subtle.exportKey('jwk', key); return jwk as Jwk>; }; -export const generateKey = async (): Promise> => { - const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - return key as Key<{ name: 'AES-GCM'; length: 256 }>; +export const generate = async (): Promise> => { + const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + return key; }; export const decrypt = async (key: Key, content: AesEncryptedContent): Promise => { - const decrypted = await crypto.subtle.decrypt( + const decrypted = await subtle.decrypt( { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, key, content.ciphertext, @@ -78,7 +80,7 @@ export const decrypt = async (key: Key, content: AesEncryptedContent): Promise): Promise => { const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16; - const iv = crypto.getRandomValues(new Uint8Array(ivLength)); - const ciphertext = await crypto.subtle.encrypt({ name: key.algorithm.name, iv }, key, plaintext); + const iv = getRandomValues(new Uint8Array(ivLength)); + const ciphertext = await subtle.encrypt({ name: key.algorithm.name, iv }, key, plaintext); return { iv, ciphertext: new Uint8Array(ciphertext) }; }; diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts similarity index 95% rename from apps/meteor/client/lib/e2ee/pbkdf2.spec.ts rename to apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts index f832cf2b9d976..de0b1399d4820 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts @@ -1,9 +1,5 @@ -import { webcrypto } from 'node:crypto'; - import { importBaseKey, deriveBits, importKey } from './pbkdf2'; -Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); - describe('pbkdf2', () => { it('should import a base key', async () => { const keyData = new Uint8Array([1, 2, 3, 4, 5]); diff --git a/apps/meteor/client/lib/e2ee/pbkdf2.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts similarity index 82% rename from apps/meteor/client/lib/e2ee/pbkdf2.ts rename to apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts index 473cabdaaf3a9..e7d00c5fa794b 100644 --- a/apps/meteor/client/lib/e2ee/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts @@ -1,3 +1,5 @@ +import { subtle, getRandomValues } from './shared'; + export type Options = { salt: Uint8Array; iterations: number; @@ -18,7 +20,7 @@ export type BaseKey = Narrow< >; export const importBaseKey = async (keyData: Uint8Array): Promise => { - const baseKey = await crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, ['deriveBits']); + const baseKey = await subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, ['deriveBits']); return baseKey as BaseKey; }; @@ -38,7 +40,7 @@ type FixedSizeArrayBuffer = Narrow< export type DerivedBits = FixedSizeArrayBuffer<32>; export const deriveBits = async (key: BaseKey, options: Options): Promise => { - const bits = await crypto.subtle.deriveBits( + const bits = await subtle.deriveBits( { name: key.algorithm.name, hash: 'SHA-256', salt: options.salt, iterations: options.iterations }, key, 256, @@ -60,12 +62,12 @@ export type DerivedKey(derivedBits: DerivedBits, algorithm: T): Promise> => { const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; - const key = await crypto.subtle.importKey('raw', derivedBits, { name: algorithm, length: 256 } satisfies AesKeyGenParams, false, usages); + const key = await subtle.importKey('raw', derivedBits, { name: algorithm, length: 256 } satisfies AesKeyGenParams, false, usages); return key as DerivedKey; }; export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise> => { - const decrypted = await crypto.subtle.decrypt( + const decrypted = await subtle.decrypt( { name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams, key, content.ciphertext, @@ -75,7 +77,7 @@ export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promi export const encrypt = async (key: DerivedKey<'AES-GCM'>, data: Uint8Array): Promise => { // Always use AES-GCM for new data - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv } satisfies AesGcmParams, key, data); + const iv = getRandomValues(new Uint8Array(12)); + const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv } satisfies AesGcmParams, key, data); return { iv, ciphertext: new Uint8Array(ciphertext) }; }; diff --git a/apps/meteor/client/lib/e2ee/rsa.spec.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts similarity index 92% rename from apps/meteor/client/lib/e2ee/rsa.spec.ts rename to apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts index 3285db50275c4..ee6d0a53ca125 100644 --- a/apps/meteor/client/lib/e2ee/rsa.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts @@ -1,23 +1,19 @@ -import { webcrypto } from 'node:crypto'; - import { - generateKeyPair, + generate, decrypt, encrypt, exportPublicKey, exportPrivateKey, importPrivateKey, importPublicKey, - type IKeyPair, + type KeyPair, } from './rsa'; -Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); - describe('rsa', () => { - let keyPair: IKeyPair; + let keyPair: KeyPair; beforeAll(async () => { - keyPair = await generateKeyPair(); + keyPair = await generate(); }); it('generate a key pair with correct properties', async () => { @@ -60,6 +56,7 @@ describe('rsa', () => { expect(privateJwk.n).toEqual(publicJwk.n); const importedPublicKey = await importPublicKey(publicJwk); + expect<2048>(importedPublicKey.algorithm.modulusLength).toBe(2048); expect<'RSA-OAEP'>(importedPublicKey.algorithm.name).toBe('RSA-OAEP'); expect(importedPublicKey.extractable).toBe(true); expect<'public'>(importedPublicKey.type).toBe('public'); @@ -67,6 +64,7 @@ describe('rsa', () => { expect<['encrypt']>(importedPublicKey.usages).toEqual(['encrypt']); const importedPrivateKey = await importPrivateKey(privateJwk); + expect<2048>(importedPrivateKey.algorithm.modulusLength).toBe(2048); expect<'RSA-OAEP'>(importedPrivateKey.algorithm.name).toBe('RSA-OAEP'); expect(importedPrivateKey.extractable).toBe(true); expect<'private'>(importedPrivateKey.type).toBe('private'); diff --git a/apps/meteor/client/lib/e2ee/crypto/rsa.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.ts new file mode 100644 index 0000000000000..70d2a8d14bf77 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.ts @@ -0,0 +1,107 @@ +import { generateKeyPair, exportKey, importKey, type IKeyPair, subtle } from './shared'; + +export type KeyPair = IKeyPair< + { + readonly name: 'RSA-OAEP'; + readonly modulusLength: 2048; + readonly publicExponent: Uint8Array; + readonly hash: { + readonly name: 'SHA-256'; + }; + }, + true, + ['encrypt', 'decrypt'] +>; + +export type PublicKey = KeyPair['publicKey']; + +export type PrivateKey = KeyPair['privateKey']; + +export const generate = async (): Promise => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + + return keyPair; +}; + +type Base64Url = string; + +export interface IPublicJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + key_ops: ['encrypt']; + n: Base64Url; +} + +export interface IPrivateJwk { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: 'AQAB'; + ext: true; + d: Base64Url; + dp: Base64Url; + dq: Base64Url; + key_ops: ['decrypt']; + n: Base64Url; + p: Base64Url; + q: Base64Url; + qi: Base64Url; +} + +export const exportPublicKey = async (key: PublicKey): Promise => { + const jwk = await exportKey('jwk', key); + return jwk as IPublicJwk; +}; + +export const exportPrivateKey = async (key: PrivateKey): Promise => { + const jwk = await exportKey('jwk', key); + return jwk as IPrivateJwk; +}; + +export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { + const key = await importKey( + 'jwk', + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }, + true, + ['decrypt'], + ); + return key as PrivateKey; +}; + +export const importPublicKey = async (keyData: IPublicJwk): Promise => { + const key = await importKey( + 'jwk', + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt'], + ); + return key as PublicKey; +}; + +export const encrypt = async (key: PublicKey, data: BufferSource): Promise> => { + const encrypted = await subtle.encrypt({ name: key.algorithm.name } satisfies RsaOaepParams, key, data); + return new Uint8Array(encrypted); +}; + +export const decrypt = async (key: PrivateKey, data: BufferSource): Promise> => { + const decrypted = await subtle.decrypt({ name: key.algorithm.name } satisfies RsaOaepParams, key, data); + return new Uint8Array(decrypted); +}; diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts new file mode 100644 index 0000000000000..2b0911ff1a038 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts @@ -0,0 +1,131 @@ +import { generateKey, generateKeyPair, exportKey, importKey } from './shared'; + +describe('Shared Crypto Functions', () => { + describe('generateKey', () => { + it('should generate an AES key with correct properties', async () => { + const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + expect<'AES-GCM'>(key.algorithm.name).toBe('AES-GCM'); + expect<256>(key.algorithm.length).toBe(256); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt'])); + }); + + it('should export a key to JWK format', async () => { + const key = await generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt']); + const exportedKey = await exportKey('jwk', key); + expect(Object.keys(exportedKey)).toHaveLength(5); + expect(exportedKey.ext).toBe(true); + expect<'oct'>(exportedKey.kty).toBe('oct'); + expect(exportedKey.k).toHaveLength(22); // 128 bits in base64url + expect<`A128CBC`>(exportedKey.alg).toBe('A128CBC'); + expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']); + }); + }); + + describe('generateKeyPair', () => { + it('should generate an RSA key pair with correct properties', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + + expect<'RSA-OAEP'>(keyPair.publicKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.publicKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.publicKey.extractable).toBe(true); + expect<'public'>(keyPair.publicKey.type).toBe('public'); + expect<1>(keyPair.publicKey.usages.length).toBe(1); + expect<['encrypt']>(keyPair.publicKey.usages).toEqual(['encrypt']); + + expect<'RSA-OAEP'>(keyPair.privateKey.algorithm.name).toBe('RSA-OAEP'); + expect<2048>(keyPair.privateKey.algorithm.modulusLength).toBe(2048); + expect(keyPair.privateKey.extractable).toBe(true); + expect<'private'>(keyPair.privateKey.type).toBe('private'); + expect<1>(keyPair.privateKey.usages.length).toBe(1); + expect<['decrypt']>(keyPair.privateKey.usages).toEqual(['decrypt']); + }); + }); + + describe('importKey', () => { + it('should import an AES key from JWK format', async () => { + const key = await importKey( + 'jwk', + { + kty: 'oct' as const, + k: 'qb8In0Rpa9nwSusvxxDcbQ', + key_ops: ['encrypt', 'decrypt'] as const, + ext: true, + alg: 'A128CBC' as const, + }, + { name: 'AES-CBC', length: 128 }, + true, + ['encrypt', 'decrypt'], + ); + expect<128>(key.algorithm.length).toBe(128); + expect<'AES-CBC'>(key.algorithm.name).toBe('AES-CBC'); + expect(key.extractable).toBe(true); + expect<'secret'>(key.type).toBe('secret'); + expect<2>(key.usages.length).toBe(2); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(key.usages).toEqual(expect.arrayContaining(['encrypt', 'decrypt'])); + }); + }); + + describe('exportKey', () => { + it('should export an RSA public key to JWK format', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + const exportedKey = await exportKey('jwk', keyPair.publicKey); + expect(Object.keys(exportedKey).toSorted()).toEqual(['alg', 'e', 'ext', 'key_ops', 'kty', 'n'].toSorted()); + expect(exportedKey.ext).toBe(true); + expect<'RSA'>(exportedKey.kty).toBe('RSA'); + expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256'); + expect<['encrypt']>(exportedKey.key_ops).toEqual(['encrypt']); + expect(exportedKey.n).toBeDefined(); + expect(exportedKey.e).toBeDefined(); + }); + + it('should export an RSA private key to JWK format', async () => { + const keyPair = await generateKeyPair( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['encrypt', 'decrypt'], + ); + const exportedKey = await exportKey('jwk', keyPair.privateKey); + expect(Object.keys(exportedKey).toSorted()).toEqual( + ['alg', 'd', 'dp', 'dq', 'e', 'ext', 'key_ops', 'kty', 'n', 'p', 'q', 'qi'].toSorted(), + ); + expect(exportedKey.ext).toBe(true); + expect<'RSA'>(exportedKey.kty).toBe('RSA'); + expect<`RSA-OAEP-256`>(exportedKey.alg).toBe('RSA-OAEP-256'); + expect<['decrypt']>(exportedKey.key_ops).toEqual(['decrypt']); + expect(exportedKey.n).toBeDefined(); + expect(exportedKey.e).toBeDefined(); + expect(exportedKey.d).toBeDefined(); + expect(exportedKey.p).toBeDefined(); + expect(exportedKey.q).toBeDefined(); + expect(exportedKey.dp).toBeDefined(); + expect(exportedKey.dq).toBeDefined(); + expect(exportedKey.qi).toBeDefined(); + }); + }); +}); diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.ts b/apps/meteor/client/lib/e2ee/crypto/shared.ts new file mode 100644 index 0000000000000..d9e30002616f8 --- /dev/null +++ b/apps/meteor/client/lib/e2ee/crypto/shared.ts @@ -0,0 +1,150 @@ +const crypto: Crypto = (() => { + if (typeof globalThis.crypto !== 'undefined' && 'subtle' in globalThis.crypto) { + return globalThis.crypto as Crypto; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/consistent-type-imports + const { webcrypto } = require('node:crypto') as typeof import('node:crypto'); + return webcrypto as Crypto; +})(); + +export const { subtle } = crypto; +export const randomUUID = crypto.randomUUID.bind(crypto); +export const getRandomValues = crypto.getRandomValues.bind(crypto); + +type AesParams = { + name: 'AES-CBC' | 'AES-GCM' | 'AES-CTR'; + length: 128 | 256; +}; + +type ModeOf = T extends `AES-${infer Mode}` ? Mode : never; + +type Permutations = T extends [infer First] + ? [First] + : T extends [infer First, infer Second] + ? [First, Second] | [Second, First] + : never; + +export interface IKey< + TAlgorithm extends CryptoKey['algorithm'] = CryptoKey['algorithm'], + TExtractable extends CryptoKey['extractable'] = CryptoKey['extractable'], + TType extends CryptoKey['type'] = CryptoKey['type'], + TUsages extends CryptoKey['usages'] = CryptoKey['usages'], +> extends CryptoKey { + readonly algorithm: TAlgorithm; + readonly extractable: TExtractable; + readonly type: TType; + readonly usages: Permutations; +} + +export async function generateKey< + const T extends AesParams, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise> { + const key = await subtle.generateKey(algorithm, extractable, keyUsages); + return key as IKey; +} + +type Filter = TArray extends [ + infer First extends KeyUsage, + ...infer Rest extends KeyUsage[], +] + ? First extends TItem + ? Filter + : Filter + : Out; + +export interface IKeyPair< + T extends KeyAlgorithm = KeyAlgorithm, + TExtractable extends boolean = boolean, + TUsages extends KeyUsage[] = KeyUsage[], +> extends CryptoKeyPair { + publicKey: IKey>; + privateKey: IKey>; +} + +type RsaParams = { + name: 'RSA-OAEP'; + modulusLength: 2048; + publicExponent: Uint8Array; + hash: { name: 'SHA-256' }; +}; + +export async function generateKeyPair< + const T extends RsaParams, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>(algorithm: T, extractable: TExtractable, keyUsages: TUsages): Promise> { + const keyPair = await subtle.generateKey(algorithm, extractable, keyUsages); + return keyPair as IKeyPair; +} + +type KeyToJwk = T['extractable'] extends false + ? never + : T['algorithm'] extends AesParams + ? { + kty: 'oct'; + alg: `A${T['algorithm']['length']}${ModeOf}`; + ext: true; + k: string; + key_ops: T['usages']; + } + : [T['algorithm'], T['type']] extends [RsaParams, 'public'] + ? { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: string; + ext: true; + key_ops: T['usages']; + n: string; + } + : [T['algorithm'], T['type']] extends [RsaParams, 'private'] + ? { + kty: 'RSA'; + alg: 'RSA-OAEP-256'; + e: string; + ext: true; + d: string; + dp: string; + dq: string; + key_ops: T['usages']; + n: string; + p: string; + q: string; + qi: string; + } + : never; + +type KeyExportType = TFormat extends 'jwk' ? KeyToJwk : ArrayBuffer; + +export async function exportKey( + format: TFormat, + key: TKey, +): Promise> { + const exportedKey = await subtle.exportKey(format, key); + return exportedKey as KeyExportType; +} + +export async function importKey< + const TFormat extends KeyFormat = KeyFormat, + const TKeyData extends TFormat extends 'jwk' ? JsonWebKey : ArrayBuffer = TFormat extends 'jwk' ? JsonWebKey : ArrayBuffer, + const TAlgorithm extends KeyAlgorithm = KeyAlgorithm, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], + const TKeyType extends KeyType = TKeyData extends { kty: 'oct' } + ? 'secret' + : TKeyData extends { kty: 'RSA'; key_ops: [infer Op] } + ? Op extends 'encrypt' + ? 'public' + : 'private' + : never, +>( + format: TFormat, + keyData: TKeyData, + algorithm: TAlgorithm, + extractable: TExtractable, + keyUsages: TUsages, +): Promise> { + const key = await subtle.importKey(format as any, keyData as any, algorithm, extractable, keyUsages); + return key as IKey; +} diff --git a/apps/meteor/client/lib/e2ee/keychain.spec.ts b/apps/meteor/client/lib/e2ee/keychain.spec.ts index c788825eecd80..4bc9753290b39 100644 --- a/apps/meteor/client/lib/e2ee/keychain.spec.ts +++ b/apps/meteor/client/lib/e2ee/keychain.spec.ts @@ -1,9 +1,5 @@ -import { webcrypto } from 'node:crypto'; - import { Keychain } from './keychain'; -Object.assign(globalThis.crypto, { subtle: webcrypto.subtle }); - describe('Keychain', () => { /** * Calculates the length of a base64-encoded string given the length of the original byte array. diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index 8d84f78afaf71..b710b68b968b4 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -2,7 +2,8 @@ import { Base64 } from '@rocket.chat/base64'; import { Binary } from './binary'; import type { ICodec } from './codec'; -import * as Pbkdf2 from './pbkdf2'; +import * as Pbkdf2 from './crypto/pbkdf2'; +import { randomUUID } from './crypto/shared'; /** * Version 1 format: @@ -149,7 +150,7 @@ export class Keychain { } async encryptKey(privateKey: string, password: string): Promise { - const salt = `v2:${this.userId}:${crypto.randomUUID()}`; + const salt = `v2:${this.userId}:${randomUUID()}`; const iterations = 100_000; const algorithm = 'AES-GCM'; const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 02bc04a400554..9a80dfb4b073c 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -15,14 +15,14 @@ import type { Optional } from '@tanstack/react-query'; import EJSON from 'ejson'; import type { E2ERoomState } from './E2ERoomState'; -import * as Aes from './aes'; import { Binary } from './binary'; import { decodeEncryptedContent } from './content'; +import * as Aes from './crypto/aes'; +import * as Rsa from './crypto/rsa'; import { encryptAESCTR, generateAESCTRKey, sha256HashFromArrayBuffer, createSha256HashFromText } from './helper'; import { createLogger } from './logger'; import { PrefixedBase64 } from './prefixed'; import { e2e } from './rocketchat.e2e'; -import * as Rsa from './rsa'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { t } from '../../../app/utils/lib/i18n'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; @@ -86,7 +86,7 @@ export class E2ERoom extends Emitter { sessionKeyExportedString: string | undefined; - sessionKeyExported: JsonWebKey | undefined; + sessionKeyExported: Aes.Jwk | undefined; constructor(userId: string, room: IRoom) { super(); @@ -414,7 +414,7 @@ export class E2ERoom extends Emitter { } async createNewGroupKey() { - this.groupSessionKey = await Aes.generateKey(); + this.groupSessionKey = await Aes.generate(); const sessionKeyExported = await Aes.exportKey(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); this.keyID = crypto.randomUUID(); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 1b7a9693cf571..1833e06daa246 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -10,11 +10,11 @@ import _ from 'lodash'; import { Accounts } from 'meteor/accounts-base'; import type { E2EEState } from './E2EEState'; +import * as Rsa from './crypto/rsa'; import { generatePassphrase } from './helper'; import { Keychain } from './keychain'; import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; -import * as Rsa from './rsa'; import { limitQuoteChain } from '../../../app/ui-message/client/messageBox/limitQuoteChain'; import { getUserAvatarURL } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; @@ -22,7 +22,7 @@ import { t } from '../../../app/utils/lib/i18n'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; import { isTruthy } from '../../../lib/isTruthy'; -import { Messages, Rooms, Subscriptions } from '../../stores'; +import { Rooms, Subscriptions } from '../../stores'; import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal'; import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal'; import * as banners from '../banners'; @@ -53,7 +53,7 @@ class E2E extends Emitter { private db_private_key: string | null | undefined; - public privateKey: Rsa.IPrivateKey | undefined; + public privateKey: Rsa.PrivateKey | undefined; public publicKey: string | undefined; @@ -478,7 +478,7 @@ class E2E extends Emitter { this.setState('LOADING_KEYS'); let keyPair; try { - keyPair = await Rsa.generateKeyPair(); + keyPair = await Rsa.generate(); this.privateKey = keyPair.privateKey; } catch (error) { this.setState('ERROR'); @@ -663,13 +663,6 @@ class E2E extends Emitter { return message; } - async decryptPendingMessages(): Promise { - await Messages.state.updateAsync( - (record) => record.t === 'e2e' && record.e2e === 'pending', - (record) => this.decryptMessage(record), - ); - } - async decryptSubscription(subscription: SubscriptionWithRoom): Promise { const span = log.span('decryptSubscription'); const e2eRoom = await this.getInstanceByRoomId(subscription.rid); @@ -801,14 +794,9 @@ class E2E extends Emitter { } getUserId(): string { - const span = log.span('getUserId'); if (!this.userId) { - span.error('No userId found'); - throw new Error('No userId found'); } - - span.set('userId', this.userId).info('found userId'); return this.userId; } diff --git a/apps/meteor/client/lib/e2ee/rsa.ts b/apps/meteor/client/lib/e2ee/rsa.ts deleted file mode 100644 index 5bdc803138ebb..0000000000000 --- a/apps/meteor/client/lib/e2ee/rsa.ts +++ /dev/null @@ -1,120 +0,0 @@ -type HashAlgorithm = { name: 'SHA-256' }; -type ModulusLength = 2048; -type Name = 'RSA-OAEP'; - -interface IRsaAlgorithm extends RsaHashedKeyAlgorithm { - readonly name: Name; - readonly modulusLength: ModulusLength; - readonly publicExponent: Uint8Array; - readonly hash: HashAlgorithm; -} - -export interface IKey extends CryptoKey { - algorithm: IRsaAlgorithm; -} - -export interface IPrivateKey extends IKey { - type: 'private'; - usages: ['decrypt']; - extractable: true; -} - -export interface IPublicKey extends IKey { - type: 'public'; - usages: ['encrypt']; - extractable: true; -} - -export interface IKeyPair extends CryptoKeyPair { - publicKey: IPublicKey; - privateKey: IPrivateKey; -} - -export const generateKeyPair = async (): Promise => { - const keyPair = await crypto.subtle.generateKey( - { - name: 'RSA-OAEP', - modulusLength: 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: { name: 'SHA-256' }, - } satisfies IRsaAlgorithm, - true, - ['encrypt', 'decrypt'], - ); - - return keyPair as IKeyPair; -}; - -type Base64Url = string; - -export interface IPublicJwk { - kty: 'RSA'; - alg: 'RSA-OAEP-256'; - e: 'AQAB'; - ext: true; - key_ops: ['encrypt']; - n: Base64Url; -} - -export interface IPrivateJwk { - kty: 'RSA'; - alg: 'RSA-OAEP-256'; - e: 'AQAB'; - ext: true; - d: Base64Url; - dp: Base64Url; - dq: Base64Url; - key_ops: ['decrypt']; - n: Base64Url; - p: Base64Url; - q: Base64Url; - qi: Base64Url; -} - -export const exportPublicKey = async (key: IPublicKey): Promise => { - const jwk = await crypto.subtle.exportKey('jwk', key); - return jwk as IPublicJwk; -}; - -export const exportPrivateKey = async (key: IPrivateKey): Promise => { - const jwk = await crypto.subtle.exportKey('jwk', key); - return jwk as IPrivateJwk; -}; - -export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { - const key = await crypto.subtle.importKey( - 'jwk', - keyData, - { - name: 'RSA-OAEP', - hash: { name: 'SHA-256' }, - }, - true, - ['decrypt'], - ); - return key as IPrivateKey; -}; - -export const importPublicKey = async (keyData: IPublicJwk): Promise => { - const key = await crypto.subtle.importKey( - 'jwk', - keyData, - { - name: 'RSA-OAEP', - hash: { name: 'SHA-256' }, - }, - true, - ['encrypt'], - ); - return key as IPublicKey; -}; - -export const encrypt = async (key: IPublicKey, data: BufferSource): Promise> => { - const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); - return new Uint8Array(encrypted); -}; - -export const decrypt = async (key: IPrivateKey, data: BufferSource): Promise> => { - const decrypted = await crypto.subtle.decrypt({ name: 'RSA-OAEP' } satisfies RsaOaepParams, key, data); - return new Uint8Array(decrypted); -}; From 07f115846f4920418723247ea877da3933dda259 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 22 Oct 2025 14:14:45 -0300 Subject: [PATCH 246/251] fix: formatting --- apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts index ee6d0a53ca125..ec7024c503f69 100644 --- a/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.spec.ts @@ -1,13 +1,4 @@ -import { - generate, - decrypt, - encrypt, - exportPublicKey, - exportPrivateKey, - importPrivateKey, - importPublicKey, - type KeyPair, -} from './rsa'; +import { generate, decrypt, encrypt, exportPublicKey, exportPrivateKey, importPrivateKey, importPublicKey, type KeyPair } from './rsa'; describe('rsa', () => { let keyPair: KeyPair; From 7e682734bd6170a9eca41686dd8e51a65a8bd775 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 22 Oct 2025 16:34:01 -0300 Subject: [PATCH 247/251] refactor: type-safe crypto --- .../meteor/client/lib/e2ee/crypto/aes.spec.ts | 10 +-- apps/meteor/client/lib/e2ee/crypto/aes.ts | 80 +++++++----------- .../client/lib/e2ee/crypto/pbkdf2.spec.ts | 14 ++-- apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts | 68 +++++++++------- apps/meteor/client/lib/e2ee/crypto/rsa.ts | 16 ++-- .../client/lib/e2ee/crypto/shared.spec.ts | 16 +++- apps/meteor/client/lib/e2ee/crypto/shared.ts | 81 ++++++++++++++++--- apps/meteor/client/lib/e2ee/keychain.ts | 8 +- .../client/lib/e2ee/rocketchat.e2e.room.ts | 2 +- 9 files changed, 177 insertions(+), 118 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts index aaef2e81409cd..d212b469623f0 100644 --- a/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/aes.spec.ts @@ -1,4 +1,4 @@ -import { generate, decrypt, encrypt, exportKey, importKey, type Key } from './aes'; +import { generate, decrypt, encrypt, exportJwk, importKey, type Key } from './aes'; describe('aes', () => { describe('256-gcm', () => { @@ -25,7 +25,7 @@ describe('aes', () => { }); it('should export and re-import the key correctly', async () => { - const jwk = await exportKey(key); + const jwk = await exportJwk(key); const importedKey = await importKey(jwk); expect<256>(importedKey.algorithm.length).toBe(256); expect<'AES-GCM'>(importedKey.algorithm.name).toBe('AES-GCM'); @@ -60,7 +60,7 @@ describe('aes', () => { }); it('should export and re-import the key correctly', async () => { - const jwk = await exportKey(key); + const jwk = await exportJwk(key); const importedKey = await importKey(jwk); expect<128>(importedKey.algorithm.length).toBe(128); expect<'AES-CBC'>(importedKey.algorithm.name).toBe('AES-CBC'); @@ -81,7 +81,7 @@ describe('aes', () => { key_ops: ['encrypt', 'decrypt'], kty: 'oct', }), - ).rejects.toThrow('Unsupported JWK alg: A128GCM'); + ).rejects.toThrow('Unrecognized algorithm name'); await expect( importKey({ @@ -92,6 +92,6 @@ describe('aes', () => { key_ops: ['encrypt', 'decrypt'], kty: 'oct', }), - ).rejects.toThrow('Unsupported JWK alg: A256CBC'); + ).rejects.toThrow('Unrecognized algorithm name'); }); }); diff --git a/apps/meteor/client/lib/e2ee/crypto/aes.ts b/apps/meteor/client/lib/e2ee/crypto/aes.ts index ad263eb3c34d2..591c9e64650a6 100644 --- a/apps/meteor/client/lib/e2ee/crypto/aes.ts +++ b/apps/meteor/client/lib/e2ee/crypto/aes.ts @@ -1,33 +1,31 @@ -import { subtle, getRandomValues, generateKey, type IKey } from './shared'; +import { importJwk, exportKey, getRandomValues, generateKey, type IKey, type Exported, encryptBuffer, decryptBuffer } from './shared'; -type AesKeyAlgorithmLength = 128 | 256; +type AlgorithmMap = { + A256GCM: { name: 'AES-GCM'; length: 256 }; + A128CBC: { name: 'AES-CBC'; length: 128 }; +}; -interface IAesCbcKeyAlgorithm extends AesKeyAlgorithm { - name: 'AES-CBC'; - length: TLength; -} +const ALGORITHM_MAP: AlgorithmMap = { + A256GCM: { name: 'AES-GCM', length: 256 }, + A128CBC: { name: 'AES-CBC', length: 128 }, +}; -interface IAesGcmKeyAlgorithm extends AesKeyAlgorithm { - name: 'AES-GCM'; - length: TLength; -} +type Jwa = keyof AlgorithmMap; +type Algorithms = AlgorithmMap[Jwa]; -type SupportedAesKeyAlgorithm = IAesCbcKeyAlgorithm<128> | IAesGcmKeyAlgorithm<256>; +export type Key = IKey< + TAlgorithm, + TExtractable, + 'secret', + ['encrypt', 'decrypt'] +>; -type JwkAlg = 'A128CBC' | 'A256GCM'; -export type Jwk = { +export type Jwk = { kty: 'oct'; k: string; key_ops: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; ext: true; - alg: TAlg; -}; - -export type Key = CryptoKey & { - algorithm: TAlgorithm; - extractable: true; - type: 'secret'; - usages: ['encrypt', 'decrypt'] | ['decrypt', 'encrypt']; + alg: TJwa; }; type AesEncryptedContent = { @@ -35,44 +33,22 @@ type AesEncryptedContent = { ciphertext: Uint8Array; }; -type ToJwkAlg = - TAlgorithm extends IAesGcmKeyAlgorithm<256> ? 'A256GCM' : TAlgorithm extends IAesCbcKeyAlgorithm<128> ? 'A128CBC' : never; - -const JWK_ALG_TO_AES_KEY_ALGORITHM = { - A256GCM: { name: 'AES-GCM', length: 256 }, - A128CBC: { name: 'AES-CBC', length: 128 }, -} satisfies Record; - -export const importKey = async (jwk: TJwk): Promise> => { - if (!('alg' in jwk)) { - throw new Error('JWK alg property is required to import the key'); - } - - if (jwk.alg !== 'A256GCM' && jwk.alg !== 'A128CBC') { - throw new Error(`Unsupported JWK alg: ${jwk.alg}`); - } - - const algorithm = JWK_ALG_TO_AES_KEY_ALGORITHM[jwk.alg]; - - const key = await subtle.importKey('jwk', jwk, algorithm, true, ['encrypt', 'decrypt']); - - return key as Key<(typeof JWK_ALG_TO_AES_KEY_ALGORITHM)[TJwk['alg']]>; +export const importKey = (jwk: Jwk): Promise> => { + return importJwk(jwk, ALGORITHM_MAP[jwk.alg], true, ['encrypt', 'decrypt']); }; -export const exportKey = async (key: Key): Promise>> => { - const jwk = await subtle.exportKey('jwk', key); - return jwk as Jwk>; +export const exportJwk = (key: Key): Promise>> => { + return exportKey('jwk', key); }; -export const generate = async (): Promise> => { - const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); - return key; +export const generate = (): Promise> => { + return generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); }; export const decrypt = async (key: Key, content: AesEncryptedContent): Promise => { - const decrypted = await subtle.decrypt( - { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, + const decrypted = await decryptBuffer( key, + { name: key.algorithm.name, iv: content.iv } satisfies AesGcmParams | AesCbcParams, content.ciphertext, ); return new TextDecoder().decode(decrypted); @@ -81,6 +57,6 @@ export const decrypt = async (key: Key, content: AesEncryptedContent): Promise): Promise => { const ivLength = key.algorithm.name === 'AES-GCM' ? 12 : 16; const iv = getRandomValues(new Uint8Array(ivLength)); - const ciphertext = await subtle.encrypt({ name: key.algorithm.name, iv }, key, plaintext); + const ciphertext = await encryptBuffer(key, { name: key.algorithm.name, iv }, plaintext); return { iv, ciphertext: new Uint8Array(ciphertext) }; }; diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts index de0b1399d4820..01b66c27526dc 100644 --- a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.spec.ts @@ -1,4 +1,4 @@ -import { importBaseKey, deriveBits, importKey } from './pbkdf2'; +import { importBaseKey, derive, importKey } from './pbkdf2'; describe('pbkdf2', () => { it('should import a base key', async () => { @@ -16,7 +16,7 @@ describe('pbkdf2', () => { const salt = new Uint8Array([5, 4, 3, 2, 1]); const iterations = 1000; const baseKey = await importBaseKey(keyData); - const derivedBits = await deriveBits(baseKey, { salt, iterations }); + const derivedBits = await derive(baseKey, { salt, iterations }); expect(derivedBits.detached).toBe(false); expect(derivedBits.resizable).toBe(false); expect<32>(derivedBits.byteLength).toBe(32); @@ -26,8 +26,8 @@ describe('pbkdf2', () => { it('should import a derived key for AES-CBC', async () => { const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); - const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); - const derivedKey = await importKey(derivedBits, 'AES-CBC'); + const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const derivedKey = await importKey(derivedBits, { name: 'AES-CBC', length: 256 }); expect<256>(derivedKey.algorithm.length).toBe(256); expect<'AES-CBC'>(derivedKey.algorithm.name).toBe('AES-CBC'); expect(derivedKey.extractable).toBe(false); @@ -38,12 +38,12 @@ describe('pbkdf2', () => { it('should import a derived key for AES-GCM', async () => { const baseKey = await importBaseKey(new Uint8Array([1, 2, 3, 4, 5])); - const derivedBits = await deriveBits(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); - const derivedKey = await importKey(derivedBits, 'AES-GCM'); + const derivedBits = await derive(baseKey, { salt: new Uint8Array([5, 4, 3, 2, 1]), iterations: 1000 }); + const derivedKey = await importKey(derivedBits, { name: 'AES-GCM', length: 256 }); expect<256>(derivedKey.algorithm.length).toBe(256); expect<'AES-GCM'>(derivedKey.algorithm.name).toBe('AES-GCM'); expect(derivedKey.extractable).toBe(false); expect<'secret'>(derivedKey.type).toBe('secret'); - expect<['encrypt', 'decrypt']>(derivedKey.usages).toEqual(['encrypt', 'decrypt']); + expect<['encrypt', 'decrypt'] | ['decrypt', 'encrypt']>(derivedKey.usages).toEqual(['encrypt', 'decrypt']); }); }); diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts index e7d00c5fa794b..007fb8b06ca6f 100644 --- a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts @@ -1,4 +1,23 @@ -import { subtle, getRandomValues } from './shared'; +import { importRaw, getRandomValues, decryptBuffer, encryptBuffer, deriveBits, type IKey } from './shared'; + +type AlgorithmMap = { + A256GCM: { name: 'AES-GCM'; length: 256 }; + A128CBC: { name: 'AES-CBC'; length: 256 }; +}; + +type Jwa = keyof AlgorithmMap; +type Algorithms = AlgorithmMap[Jwa]; + +// type KeysOfType = { +// [K in keyof T]: T[K] extends U ? K : never; +// }[keyof T]; + +export type DerivedKey = IKey< + TAlgorithm, + false, + 'secret', + TAlgorithm['name'] extends 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt'] +>; export type Options = { salt: Uint8Array; @@ -14,14 +33,18 @@ type Narrow = { [P in keyof T]: P extends keyof U ? U[P] : T[P]; }; -export type BaseKey = Narrow< - CryptoKey, - { algorithm: Narrow; extractable: false; type: 'secret'; usages: ['deriveBits'] } +export type BaseKey = IKey< + { + readonly name: 'PBKDF2'; + }, + false, + 'secret', + ['deriveBits'] >; export const importBaseKey = async (keyData: Uint8Array): Promise => { - const baseKey = await subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, ['deriveBits']); - return baseKey as BaseKey; + const baseKey = await importRaw(keyData, { name: 'PBKDF2' }, false, ['deriveBits']); + return baseKey; }; type Throws = F extends (...args: infer TArgs) => infer TRet ? (...args: TArgs) => TRet & never : never; @@ -39,8 +62,8 @@ type FixedSizeArrayBuffer = Narrow< export type DerivedBits = FixedSizeArrayBuffer<32>; -export const deriveBits = async (key: BaseKey, options: Options): Promise => { - const bits = await subtle.deriveBits( +export const derive = async (key: BaseKey, options: Options): Promise => { + const bits = await deriveBits( { name: key.algorithm.name, hash: 'SHA-256', salt: options.salt, iterations: options.iterations }, key, 256, @@ -48,36 +71,27 @@ export const deriveBits = async (key: BaseKey, options: Options): Promise = Narrow< - CryptoKey, - { - readonly algorithm: Narrow; - readonly extractable: false; - readonly type: 'secret'; - readonly usages: T extends 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; - } ->; - -export const importKey = async (derivedBits: DerivedBits, algorithm: T): Promise> => { - const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; - const key = await subtle.importKey('raw', derivedBits, { name: algorithm, length: 256 } satisfies AesKeyGenParams, false, usages); +export const importKey = async (derivedBits: DerivedBits, algorithm: T): Promise> => { + const usages: ['decrypt'] | ['encrypt', 'decrypt'] = algorithm.name === 'AES-CBC' ? ['decrypt'] : ['encrypt', 'decrypt']; + const key = await importRaw(derivedBits, algorithm satisfies AesKeyGenParams, false, usages); return key as DerivedKey; }; export const decrypt = async (key: DerivedKey, content: EncryptedContent): Promise> => { - const decrypted = await subtle.decrypt( - { name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams, + const decrypted = await decryptBuffer( key, + { name: key.algorithm.name, iv: content.iv } satisfies AesCbcParams | AesGcmParams, content.ciphertext, ); return new Uint8Array(decrypted); }; -export const encrypt = async (key: DerivedKey<'AES-GCM'>, data: Uint8Array): Promise => { +export const encrypt = async ( + key: DerivedKey<{ name: 'AES-GCM'; length: 256 }>, + data: Uint8Array, +): Promise => { // Always use AES-GCM for new data const iv = getRandomValues(new Uint8Array(12)); - const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv } satisfies AesGcmParams, key, data); + const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, data); return { iv, ciphertext: new Uint8Array(ciphertext) }; }; diff --git a/apps/meteor/client/lib/e2ee/crypto/rsa.ts b/apps/meteor/client/lib/e2ee/crypto/rsa.ts index 70d2a8d14bf77..6c15154152c65 100644 --- a/apps/meteor/client/lib/e2ee/crypto/rsa.ts +++ b/apps/meteor/client/lib/e2ee/crypto/rsa.ts @@ -1,4 +1,4 @@ -import { generateKeyPair, exportKey, importKey, type IKeyPair, subtle } from './shared'; +import { generateKeyPair, exportKey, importJwk, type IKeyPair, encryptBuffer, decryptBuffer } from './shared'; export type KeyPair = IKeyPair< { @@ -69,12 +69,13 @@ export const exportPrivateKey = async (key: PrivateKey): Promise => }; export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { - const key = await importKey( - 'jwk', + const key = await importJwk( keyData, { name: 'RSA-OAEP', hash: { name: 'SHA-256' }, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), }, true, ['decrypt'], @@ -83,12 +84,13 @@ export const importPrivateKey = async (keyData: IPrivateJwk): Promise => { - const key = await importKey( - 'jwk', + const key = await importJwk( keyData, { name: 'RSA-OAEP', hash: { name: 'SHA-256' }, + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), }, true, ['encrypt'], @@ -97,11 +99,11 @@ export const importPublicKey = async (keyData: IPublicJwk): Promise = }; export const encrypt = async (key: PublicKey, data: BufferSource): Promise> => { - const encrypted = await subtle.encrypt({ name: key.algorithm.name } satisfies RsaOaepParams, key, data); + const encrypted = await encryptBuffer(key, { name: key.algorithm.name }, data); return new Uint8Array(encrypted); }; export const decrypt = async (key: PrivateKey, data: BufferSource): Promise> => { - const decrypted = await subtle.decrypt({ name: key.algorithm.name } satisfies RsaOaepParams, key, data); + const decrypted = await decryptBuffer(key, { name: key.algorithm.name }, data); return new Uint8Array(decrypted); }; diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts index 2b0911ff1a038..6b992b83963e4 100644 --- a/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts +++ b/apps/meteor/client/lib/e2ee/crypto/shared.spec.ts @@ -1,4 +1,4 @@ -import { generateKey, generateKeyPair, exportKey, importKey } from './shared'; +import { generateKey, generateKeyPair, exportKey, importJwk, encryptBuffer, decryptBuffer, getRandomValues } from './shared'; describe('Shared Crypto Functions', () => { describe('generateKey', () => { @@ -55,8 +55,7 @@ describe('Shared Crypto Functions', () => { describe('importKey', () => { it('should import an AES key from JWK format', async () => { - const key = await importKey( - 'jwk', + const key = await importJwk( { kty: 'oct' as const, k: 'qb8In0Rpa9nwSusvxxDcbQ', @@ -128,4 +127,15 @@ describe('Shared Crypto Functions', () => { expect(exportedKey.qi).toBeDefined(); }); }); + + describe('encryptBuffer', () => { + it('should encrypt and decrypt data correctly', async () => { + const key = await generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const plaintext = new TextEncoder().encode('Test encryption'); + const iv = getRandomValues(new Uint8Array(12)); + const ciphertext = await encryptBuffer(key, { name: 'AES-GCM', iv }, plaintext); + const decrypted = await decryptBuffer(key, { name: 'AES-GCM', iv }, ciphertext); + expect(new TextDecoder().decode(decrypted)).toBe('Test encryption'); + }); + }); }); diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.ts b/apps/meteor/client/lib/e2ee/crypto/shared.ts index d9e30002616f8..4bb96d686f6a7 100644 --- a/apps/meteor/client/lib/e2ee/crypto/shared.ts +++ b/apps/meteor/client/lib/e2ee/crypto/shared.ts @@ -7,10 +7,50 @@ const crypto: Crypto = (() => { return webcrypto as Crypto; })(); -export const { subtle } = crypto; +const { subtle } = crypto; export const randomUUID = crypto.randomUUID.bind(crypto); export const getRandomValues = crypto.getRandomValues.bind(crypto); +interface IAesGcmParams extends AesGcmParams { + name: 'AES-GCM'; +} + +interface IAesCbcParams extends AesCbcParams { + name: 'AES-CBC'; +} + +interface IAesCtrParams extends AesCtrParams { + name: 'AES-CTR'; +} + +interface IRsaOaepParams extends RsaOaepParams { + name: 'RSA-OAEP'; +} + +type ParamsMap = { + 'AES-GCM': IAesGcmParams; + 'AES-CTR': IAesCtrParams; + 'AES-CBC': IAesCbcParams; + 'RSA-OAEP': IRsaOaepParams; +}; + +type ParamsOf = TKey['algorithm'] extends { name: infer TName extends keyof ParamsMap } ? ParamsMap[TName] : never; +type HasUsage = TUsage extends TKey['usages'][number] + ? TKey + : TKey & `The provided key cannot be used for ${TUsage}`; + +export const encryptBuffer = >( + key: HasUsage, + params: TParams, + data: BufferSource, +): Promise => subtle.encrypt(params, key, data) as Promise; +export const decryptBuffer = ( + key: HasUsage, + params: ParamsOf, + data: BufferSource, +): Promise => subtle.decrypt(params, key, data) as Promise; +export const deriveBits = subtle.deriveBits.bind(subtle); + type AesParams = { name: 'AES-CBC' | 'AES-GCM' | 'AES-CTR'; length: 128 | 256; @@ -115,20 +155,24 @@ type KeyToJwk = T['extractable'] extends false } : never; -type KeyExportType = TFormat extends 'jwk' ? KeyToJwk : ArrayBuffer; +export type Exported = TFormat extends 'jwk' ? KeyToJwk : ArrayBuffer; export async function exportKey( format: TFormat, key: TKey, -): Promise> { +): Promise> { const exportedKey = await subtle.exportKey(format, key); - return exportedKey as KeyExportType; + return exportedKey as Exported; } -export async function importKey< - const TFormat extends KeyFormat = KeyFormat, - const TKeyData extends TFormat extends 'jwk' ? JsonWebKey : ArrayBuffer = TFormat extends 'jwk' ? JsonWebKey : ArrayBuffer, - const TAlgorithm extends KeyAlgorithm = KeyAlgorithm, +type KtyParams = { + oct: AesParams; + RSA: RsaParams; +}; + +export async function importJwk< + const TKeyData extends JsonWebKey, + const TAlgorithm extends TKeyData extends { kty: infer Kty extends keyof KtyParams } ? KtyParams[Kty] : KeyAlgorithm, const TExtractable extends boolean = boolean, const TUsages extends KeyUsage[] = KeyUsage[], const TKeyType extends KeyType = TKeyData extends { kty: 'oct' } @@ -137,14 +181,27 @@ export async function importKey< ? Op extends 'encrypt' ? 'public' : 'private' - : never, + : KeyType, >( - format: TFormat, - keyData: TKeyData, + jwk: TKeyData, algorithm: TAlgorithm, extractable: TExtractable, keyUsages: TUsages, ): Promise> { - const key = await subtle.importKey(format as any, keyData as any, algorithm, extractable, keyUsages); + const key = await subtle.importKey('jwk', jwk, algorithm, extractable, keyUsages); return key as IKey; } + +export async function importRaw< + const TAlgorithm extends KeyAlgorithm = KeyAlgorithm, + const TExtractable extends boolean = boolean, + const TUsages extends KeyUsage[] = KeyUsage[], +>( + rawKey: BufferSource, + algorithm: TAlgorithm, + extractable: TExtractable, + keyUsages: TUsages, +): Promise> { + const key = await subtle.importKey('raw', rawKey, algorithm, extractable, keyUsages); + return key as IKey; +} diff --git a/apps/meteor/client/lib/e2ee/keychain.ts b/apps/meteor/client/lib/e2ee/keychain.ts index b710b68b968b4..00918cebe5d2b 100644 --- a/apps/meteor/client/lib/e2ee/keychain.ts +++ b/apps/meteor/client/lib/e2ee/keychain.ts @@ -140,11 +140,11 @@ export class Keychain { const { content, options } = this.codec.decode(privateKey); const algorithm = content.iv.length === 16 ? 'AES-CBC' : 'AES-GCM'; const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); - const derivedBits = await Pbkdf2.deriveBits(baseKey, { + const derivedBits = await Pbkdf2.derive(baseKey, { salt: new Uint8Array(Binary.decode(options.salt)), iterations: options.iterations, }); - const key = await Pbkdf2.importKey(derivedBits, algorithm); + const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 }); const decrypted = await Pbkdf2.decrypt(key, content); return Binary.encode(decrypted.buffer); } @@ -154,8 +154,8 @@ export class Keychain { const iterations = 100_000; const algorithm = 'AES-GCM'; const baseKey = await Pbkdf2.importBaseKey(new Uint8Array(Binary.decode(password))); - const derivedBits = await Pbkdf2.deriveBits(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations }); - const key = await Pbkdf2.importKey(derivedBits, algorithm); + const derivedBits = await Pbkdf2.derive(baseKey, { salt: new Uint8Array(Binary.decode(salt)), iterations }); + const key = await Pbkdf2.importKey(derivedBits, { name: algorithm, length: 256 }); const content = await Pbkdf2.encrypt(key, new Uint8Array(Binary.decode(privateKey))); return this.codec.encode({ content, options: { salt, iterations } }); diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index 9a80dfb4b073c..f535057383900 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -415,7 +415,7 @@ export class E2ERoom extends Emitter { async createNewGroupKey() { this.groupSessionKey = await Aes.generate(); - const sessionKeyExported = await Aes.exportKey(this.groupSessionKey); + const sessionKeyExported = await Aes.exportJwk(this.groupSessionKey); this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); this.keyID = crypto.randomUUID(); } From 65c7a72f2c170d3667a66cedf5b745f7d88a3e07 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 24 Oct 2025 12:36:11 -0300 Subject: [PATCH 248/251] fix: remove unneeded async --- apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 1833e06daa246..7632ee64e9e39 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -163,7 +163,7 @@ class E2E extends Emitter { const span = log.span('observeSubscriptions'); this.unsubscribeFromSubscriptions?.(); - this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe(async (state) => { + this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => { const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey); const subscribed = new Set(subscriptions.map((sub) => sub.rid)); From 62e2ff7dc223a0f05925cad07383fd8102b25edf Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 27 Oct 2025 12:44:41 -0300 Subject: [PATCH 249/251] chore: remove dangling comment --- apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts index 007fb8b06ca6f..65f9d6b5ec733 100644 --- a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts @@ -8,10 +8,6 @@ type AlgorithmMap = { type Jwa = keyof AlgorithmMap; type Algorithms = AlgorithmMap[Jwa]; -// type KeysOfType = { -// [K in keyof T]: T[K] extends U ? K : never; -// }[keyof T]; - export type DerivedKey = IKey< TAlgorithm, false, From b41a00e494e3fb1f02dedc1fa9cf351bac07cc6c Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 27 Oct 2025 13:01:20 -0300 Subject: [PATCH 250/251] chore: improve pbkdf2 types --- apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts index 65f9d6b5ec733..721163862dd97 100644 --- a/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts +++ b/apps/meteor/client/lib/e2ee/crypto/pbkdf2.ts @@ -1,12 +1,6 @@ import { importRaw, getRandomValues, decryptBuffer, encryptBuffer, deriveBits, type IKey } from './shared'; -type AlgorithmMap = { - A256GCM: { name: 'AES-GCM'; length: 256 }; - A128CBC: { name: 'AES-CBC'; length: 256 }; -}; - -type Jwa = keyof AlgorithmMap; -type Algorithms = AlgorithmMap[Jwa]; +type Algorithms = { name: 'AES-GCM'; length: 256 } | { name: 'AES-CBC'; length: 256 }; export type DerivedKey = IKey< TAlgorithm, From 6c48c942c725ae9c3c22bc1ba29a41fdb2a8ac64 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 27 Oct 2025 13:49:09 -0300 Subject: [PATCH 251/251] chore(jest): webcrypto in client setup --- apps/meteor/client/lib/e2ee/crypto/shared.ts | 9 --------- packages/jest-presets/src/client/jest-setup.ts | 5 +++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/meteor/client/lib/e2ee/crypto/shared.ts b/apps/meteor/client/lib/e2ee/crypto/shared.ts index 4bb96d686f6a7..ea95b651c6f57 100644 --- a/apps/meteor/client/lib/e2ee/crypto/shared.ts +++ b/apps/meteor/client/lib/e2ee/crypto/shared.ts @@ -1,12 +1,3 @@ -const crypto: Crypto = (() => { - if (typeof globalThis.crypto !== 'undefined' && 'subtle' in globalThis.crypto) { - return globalThis.crypto as Crypto; - } - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/consistent-type-imports - const { webcrypto } = require('node:crypto') as typeof import('node:crypto'); - return webcrypto as Crypto; -})(); - const { subtle } = crypto; export const randomUUID = crypto.randomUUID.bind(crypto); export const getRandomValues = crypto.getRandomValues.bind(crypto); diff --git a/packages/jest-presets/src/client/jest-setup.ts b/packages/jest-presets/src/client/jest-setup.ts index 4f0d7e39f2d6a..f513cfdca20fc 100644 --- a/packages/jest-presets/src/client/jest-setup.ts +++ b/packages/jest-presets/src/client/jest-setup.ts @@ -1,3 +1,4 @@ +import { webcrypto } from 'node:crypto'; import { TextEncoder, TextDecoder } from 'node:util'; import { toHaveNoViolations } from 'jest-axe'; @@ -10,6 +11,10 @@ expect.extend(toHaveNoViolations); const urlByBlob = new WeakMap(); const blobByUrl = new Map(); +Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, +}); + globalThis.URL.createObjectURL = (blob: Blob): string => { const url = urlByBlob.get(blob) ?? `blob://${uuid.v4()}`; urlByBlob.set(blob, url);