diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59af57043..71be0ccdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - run: bun lint:ci - run: bun tsc --noEmit - uses: supercharge/mongodb-github-action@1.12.0 - - run: RUN_MONGO_TESTS=1 bun test packages/federation-sdk/src/services/state.service.spec.ts packages/federation-sdk/src/services/room.service.spec.ts + - run: LOG_LEVEL=debug bun run test:withMongo - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: diff --git a/.vscode/settings.json b/.vscode/settings.json index 74ca7de4c..6d4a2a179 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,14 @@ "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, - "typescript.enablePromptUseWorkspaceTsdk": true - } + "typescript.enablePromptUseWorkspaceTsdk": true, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + } +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index c99b59ee9..3fff2edf7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5e5ec4a37..c9f99df21 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test": "bun test", "build": "turbo build", "test:coverage": "bun test --coverage", + "test:withMongo": "export RUN_MONGO_TESTS=1; grep -r runIfMongoExists packages/federation-sdk/src/services/ | awk -F: '/spec/ { print $1 }' | xargs bun test {}", "lint": "bunx @biomejs/biome lint --diagnostic-level=error", "lint:ci": "bunx @biomejs/biome ci --diagnostic-level=error", "lint:fix": "bunx @biomejs/biome lint --fix", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 38e65fb12..73e615ba5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ // Types export { EncryptionValidAlgorithm } from './types'; -export type { SignedEvent, SigningKey } from './types'; +export type * from './types'; // Event utilities export { signEvent } from './utils/signEvent'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1400cee3f..9d19f329d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -24,3 +24,35 @@ export type SigningKey = { publicKey: Uint8Array; sign(data: Uint8Array): Promise; }; + +export type KeyV2ServerResponse = { + // still valid for signing events + old_verify_keys: Record< + string, + { + expired_ts: number; + key: string; + } + >; + server_name: string; + signatures: Record>; + valid_until_ts: number; + // only federation requests + verify_keys: Record< + string, // keyAlgo:algoVersion => KeyId + { + key: string; // base64 encoded + } + >; +}; + +export type ServerKey = { + serverName: string; + keyId: string; + key: string; + pem: string; + + _createdAt: Date; + _updatedAt: Date; + expiresAt: Date; +}; diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index 85fe861b9..8ceccc403 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -177,7 +177,7 @@ export async function fetch( body() { if (!body) { body = new Promise((resBody, rejBody) => { - // TODO: Make @hs/core fetch size limit configurable + // TODO: Make @rocket.chat/federation-core fetch size limit configurable let total = 0; const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50 MB @@ -233,7 +233,7 @@ export async function fetch( reject(err); }); - // TODO: Make @hs/core fetch timeout configurable + // TODO: Make @rocket.chat/federation-core fetch timeout configurable request.setTimeout(20_000, () => { request.destroy(new Error('Request timed out after 20s')); }); diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index 904d39f35..19438ec71 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -6,6 +6,7 @@ export { toBinaryData, fromBinaryData, fromBase64ToBytes, + InvalidSignatureError, } from './utils/data-types'; export { isValidAlgorithm } from './utils/constants'; diff --git a/packages/crypto/src/keys/ed25519.ts b/packages/crypto/src/keys/ed25519.ts index 70cc65906..05aa478b9 100644 --- a/packages/crypto/src/keys/ed25519.ts +++ b/packages/crypto/src/keys/ed25519.ts @@ -6,6 +6,7 @@ import { ed25519PublicKeyRawToPem, } from '../rfc/8410/ed25519-pem'; import { EncryptionValidAlgorithm } from '../utils/constants'; +import { InvalidSignatureError } from '../utils/data-types'; export class Ed25519VerifierKeyImpl implements VerifierKey { algorithm = EncryptionValidAlgorithm.ed25519; @@ -44,7 +45,7 @@ export class Ed25519VerifierKeyImpl implements VerifierKey { } else if (verified) { resolve(); } else { - reject(new Error('Invalid signature')); + reject(new InvalidSignatureError()); } }, ); diff --git a/packages/crypto/src/tsconfig.json b/packages/crypto/src/tsconfig.json deleted file mode 100644 index 5f974e888..000000000 --- a/packages/crypto/src/tsconfig.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */, - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, - "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "ES2022" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - "types": [ - "bun-types" - ] /* Specify type package names to be included without being referenced in a source file. */, - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - "sourceMap": true /* Create source map files for emitted JavaScript files. */, - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - "useUnknownInCatchVariables": true /* Default catch clause variables as 'unknown' instead of 'any'. */, - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - "noUnusedParameters": true /* Raise an error when a function parameter isn't read. */, - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - "noImplicitReturns": false /* Enable error reporting for codepaths that do not explicitly return in a function. */, - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - "allowUnusedLabels": false /* Disable error reporting for unused labels. */, - "allowUnreachableCode": false /* Disable error reporting for unreachable code. */, - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } -} diff --git a/packages/crypto/src/utils/data-types.ts b/packages/crypto/src/utils/data-types.ts index d53537dce..f9a32e849 100644 --- a/packages/crypto/src/utils/data-types.ts +++ b/packages/crypto/src/utils/data-types.ts @@ -102,3 +102,7 @@ export function encodeCanonicalJson(value: unknown): string { export function fromBase64ToBytes(base64: string): Uint8Array { return Buffer.from(base64, 'base64'); } + +export class InvalidSignatureError extends Error { + name = 'InvalidSignatureError'; +} diff --git a/packages/crypto/src/utils/utils.spec.ts b/packages/crypto/src/utils/utils.spec.ts index a1182cfe2..a6da51371 100644 --- a/packages/crypto/src/utils/utils.spec.ts +++ b/packages/crypto/src/utils/utils.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { encodeCanonicalJson, fromBase64ToBytes } from './data-types'; +import { + InvalidSignatureError, + encodeCanonicalJson, + fromBase64ToBytes, +} from './data-types'; import { loadEd25519SignerFromSeed, loadEd25519VerifierFromPublicKey, @@ -99,9 +103,63 @@ describe('Signing and verifying payloads', async () => { '0', ); - expect(verifyJsonSignature({}, signature, verifier)).rejects.toThrowError( - 'Invalid signature', + expect(verifyJsonSignature({}, signature, verifier)).rejects.toThrow( + InvalidSignatureError, + ); + }); + + it('should validate signature (paranoid test 2)', async () => { + const event = { + auth_events: [ + '$Hvb-xPPDhTvlXZe2kMubgj8J7iUa5W7YvjTqMTffgUA', + '$Ulggyo4m1OlI08Z0jJDVeceigjSZP9SdEFVoAn9mEh8', + '$G2TzsvetG2YlHr20tZLHCCzOd-yxPa1jeFT8OU4_6kg', + '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs', + ], + content: { + // displayname: 'debdut1', + membership: 'join' as const, + }, + depth: 10, + hashes: { sha256: '6MnKSCFJy1fYf6ukILBEbqx2DkoaD1wRyKXhv689a0A' }, + origin: 'syn1.tunnel.dev.rocket.chat', + origin_server_ts: 1757328411218, + prev_events: ['$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs'], + room_id: '!VoUasOLSpcdtRbGHdT:syn2.tunnel.dev.rocket.chat', + sender: '@debdut1:syn1.tunnel.dev.rocket.chat', + state_key: '@debdut1:syn1.tunnel.dev.rocket.chat', + type: 'm.room.member' as const, + signatures: { + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'eJlvqxPWPe3u+BM4wOwID9YBlh/ZfVVxGYyA5WgpNs5Fe1+c36qrvCKHuXGGjfQoZFrHmZ3/GJw2pv5EvxCZAA', + }, + }, + unsigned: { + age: 1, + replaces_state: '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs', + prev_content: { displayname: 'debdut1', membership: 'invite' }, + prev_sender: '@debdut:syn2.tunnel.dev.rocket.chat', + }, + }; + + const { unsigned, signatures, ...rest } = event; + + const key = 'kryovKVnhHESOdWuZ05ViNotRMVdEh/mG2yJ0npLzEo'; + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(key), + '0', ); + + const serialized = encodeCanonicalJson(rest); + + const signature = + signatures['syn1.tunnel.dev.rocket.chat']['ed25519:a_FAET']; + + const signatureBytes = fromBase64ToBytes(signature); + + await verifier.verify(serialized, signatureBytes); }); }); diff --git a/packages/federation-sdk/src/__mocks__/block-if-no-mongo.ts b/packages/federation-sdk/src/__mocks__/block-if-no-mongo.ts new file mode 100644 index 000000000..17911b17f --- /dev/null +++ b/packages/federation-sdk/src/__mocks__/block-if-no-mongo.ts @@ -0,0 +1,8 @@ +export function runIfMongoExists(t: () => void) { + if (!process.env.RUN_MONGO_TESTS) { + console.warn('Skipping tests that require a database'); + return; + } + + t(); +} diff --git a/packages/federation-sdk/src/__mocks__/repositories.spec.ts b/packages/federation-sdk/src/__mocks__/repositories.spec.ts new file mode 100644 index 000000000..4eeb36ea5 --- /dev/null +++ b/packages/federation-sdk/src/__mocks__/repositories.spec.ts @@ -0,0 +1,50 @@ +import { + EventStagingStore, + EventStore, + ServerKey, +} from '@rocket.chat/federation-core'; +import { EventStagingRepository } from '../repositories/event-staging.repository'; +import { EventRepository } from '../repositories/event.repository'; +import { KeyRepository } from '../repositories/key.repository'; +import { Lock, LockRepository } from '../repositories/lock.repository'; +import { + StateGraphRepository, + type StateGraphStore, +} from '../repositories/state-graph.repository'; + +import { DatabaseConnectionService } from '../services/database-connection.service'; + +const databaseConfig = { + uri: 'mongodb://localhost:27017/', + name: 'matrix_test', + poolSize: 100, +}; + +const database = new DatabaseConnectionService(databaseConfig); + +const db = await database.getDb(); + +const keysCollection = db.collection('test_keys'); +const eventsCollection = db.collection('test_events'); +const eventStagingCollection = + db.collection('test_event_staging'); +const lockCollection = db.collection('test_locks'); +const statesCollection = db.collection('test_states'); + +const keyRepository = new KeyRepository(keysCollection); + +const eventStagingRepository = new EventStagingRepository( + eventStagingCollection, +); +const lockRepository = new LockRepository(lockCollection); +const stateRepository = new StateGraphRepository(statesCollection); + +const eventsRepository = new EventRepository(eventsCollection); + +export const repositories = { + keys: keyRepository, + locks: lockRepository, + eventStaging: eventStagingRepository, + states: stateRepository, + events: eventsRepository, +}; diff --git a/packages/federation-sdk/src/__mocks__/services.spec.ts b/packages/federation-sdk/src/__mocks__/services.spec.ts new file mode 100644 index 000000000..fe21c67de --- /dev/null +++ b/packages/federation-sdk/src/__mocks__/services.spec.ts @@ -0,0 +1,53 @@ +import { StagingAreaQueue } from '../queues/staging-area.queue'; +import { AppConfig, ConfigService } from '../services/config.service'; +import { EventEmitterService } from '../services/event-emitter.service'; +import { EventService } from '../services/event.service'; +import { KeyService } from '../services/key.service'; +import { SignatureVerificationService } from '../services/signature-verification.service'; +import { StateService } from '../services/state.service'; +import { repositories } from './repositories.spec'; + +const configService = new ConfigService(); + +configService.setConfig({ + signingKey: 'ed25519 0 zSkmr713LnEDbxlkYq2ZqIiKTQNsyMOU0T2CEeC44C4', + serverName: 'test.local', +} as AppConfig); + +const keyService = new KeyService(configService, repositories.keys); + +const stagingAreaQueue = new StagingAreaQueue(); + +const stateService = new StateService( + repositories.states, + repositories.events, + configService, +); + +const eventEmitter = new EventEmitterService(); + +const signatureVerificationService = new SignatureVerificationService( + keyService, + configService, +); + +const eventService = new EventService( + configService, + stagingAreaQueue, + stateService, + eventEmitter, + keyService, + signatureVerificationService, + repositories.events, + repositories.eventStaging, + repositories.locks, +); + +export { + keyService, + stateService, + stagingAreaQueue, + eventEmitter, + signatureVerificationService, + eventService, +}; diff --git a/packages/federation-sdk/src/__mocks__/singer.spec.ts b/packages/federation-sdk/src/__mocks__/singer.spec.ts new file mode 100644 index 000000000..aa2349324 --- /dev/null +++ b/packages/federation-sdk/src/__mocks__/singer.spec.ts @@ -0,0 +1,18 @@ +import { + VerifierKey, + fromBase64ToBytes, + loadEd25519SignerFromSeed, +} from '@rocket.chat/federation-crypto'; + +const seed = 'zSkmr713LnEDbxlkYq2ZqIiKTQNsyMOU0T2CEeC44C4'; + +const version = '0'; + +const signer = await loadEd25519SignerFromSeed( + fromBase64ToBytes(seed), + version, +); + +const verifier: VerifierKey = signer; + +export { verifier, signer }; diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index ced12d86a..88503057e 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -1,7 +1,10 @@ import 'reflect-metadata'; import type { Emitter } from '@rocket.chat/emitter'; -import type { EventStagingStore } from '@rocket.chat/federation-core'; +import type { + EventStagingStore, + ServerKey, +} from '@rocket.chat/federation-core'; import type { EventID, EventStore, @@ -10,7 +13,6 @@ import type { import { Collection } from 'mongodb'; import { container } from 'tsyringe'; import { StagingAreaListener } from './listeners/staging-area.listener'; -import { Key } from './repositories/key.repository'; import { Lock } from './repositories/lock.repository'; import { Room } from './repositories/room.repository'; import { Server } from './repositories/server.repository'; @@ -161,8 +163,8 @@ export async function init({ ), }); - container.register>('KeyCollection', { - useValue: db.collection('rocketchat_federation_keys'), + container.register>('KeyCollection', { + useValue: db.collection('rocketchat_federation_keys'), }); container.register>('LockCollection', { diff --git a/packages/federation-sdk/src/repositories/event-staging.repository.ts b/packages/federation-sdk/src/repositories/event-staging.repository.ts index f814bcddd..0f2459aa3 100644 --- a/packages/federation-sdk/src/repositories/event-staging.repository.ts +++ b/packages/federation-sdk/src/repositories/event-staging.repository.ts @@ -1,4 +1,3 @@ -import { generateId } from '@rocket.chat/federation-core'; import type { EventStagingStore } from '@rocket.chat/federation-core'; import { type EventID, Pdu } from '@rocket.chat/federation-room'; import type { Collection, DeleteResult, UpdateResult } from 'mongodb'; diff --git a/packages/federation-sdk/src/repositories/key.repository.ts b/packages/federation-sdk/src/repositories/key.repository.ts index 1ef3cd295..09cdfb966 100644 --- a/packages/federation-sdk/src/repositories/key.repository.ts +++ b/packages/federation-sdk/src/repositories/key.repository.ts @@ -1,30 +1,74 @@ -import { Collection } from 'mongodb'; +import { ServerKey } from '@rocket.chat/federation-core'; +import type { Collection, Filter, FindCursor, FindOptions } from 'mongodb'; import { inject, singleton } from 'tsyringe'; -export type Key = { - origin: string; - key_id: string; - public_key: string; - valid_until: Date; -}; - @singleton() export class KeyRepository { constructor( - @inject('KeyCollection') private readonly collection: Collection, + @inject('KeyCollection') private readonly collection: Collection, ) {} - async getValidPublicKeyFromLocal( - origin: string, + findByServerName( + serverName: string, + validUntil?: Date, + options?: FindOptions, + ): FindCursor { + return this.collection.find( + { + serverName, + ...(validUntil && { expiresAt: { $gt: validUntil } }), + }, + options ?? {}, + ); + } + + async findByServerNameAndKeyId( + serverName: string, keyId: string, - ): Promise { - const key = await this.collection.findOne({ - origin, - key_id: keyId, - valid_until: { $gt: new Date() }, - }); + validUntil?: Date, + options?: FindOptions, + ): Promise { + return this.collection.findOne( + { + serverName, + keyId, + ...(validUntil && { expiresAt: { $gte: validUntil } }), + }, + options ?? {}, + ); + } - return key?.public_key; + findAllByServerNameAndKeyIds( + serverName: string, + keyIds: string[], + options?: FindOptions, + ): FindCursor { + const query: Filter = { + serverName, + keyId: { $in: keyIds }, + }; + + return this.collection.find(query, options ?? {}); + } + + // cache can be refreshed + async insertOrUpdateKey(serverKey: ServerKey): Promise { + await this.collection.updateOne( + { serverName: serverKey.serverName, keyId: serverKey.keyId }, + { + $setOnInsert: { + _createdAt: new Date(), + // following shouldn't change along with keyId + key: serverKey.key, + pem: serverKey.pem, + }, + $set: { + expiresAt: serverKey.expiresAt, + _updatedAt: new Date(), + }, + }, + { upsert: true }, + ); } async storePublicKey( @@ -47,4 +91,18 @@ export class KeyRepository { { upsert: true }, ); } + + async getValidPublicKeyFromLocal( + origin: string, + keyId: string, + ): Promise { + const key = await this.collection.findOne({ + origin, + key_id: keyId, + valid_until: { $gt: new Date() }, + }); + + // @ts-ignore + return key?.public_key; + } } diff --git a/packages/federation-sdk/src/sdk.ts b/packages/federation-sdk/src/sdk.ts index 0b3093f85..f8c12c19d 100644 --- a/packages/federation-sdk/src/sdk.ts +++ b/packages/federation-sdk/src/sdk.ts @@ -9,12 +9,13 @@ import { EventService } from './services/event.service'; import { FederationRequestService } from './services/federation-request.service'; import { FederationService } from './services/federation.service'; import { InviteService } from './services/invite.service'; +import { KeyService } from './services/key.service'; import { MediaService } from './services/media.service'; import { MessageService } from './services/message.service'; import { ProfilesService } from './services/profiles.service'; import { RoomService } from './services/room.service'; import { SendJoinService } from './services/send-join.service'; -import { ServerService } from './services/server.service'; +import { SignatureVerificationService } from './services/signature-verification.service'; import { StateService } from './services/state.service'; import { WellKnownService } from './services/well-known.service'; @@ -27,7 +28,6 @@ export class FederationSDK { private readonly inviteService: InviteService, private readonly eventService: EventService, private readonly eduService: EduService, - private readonly serverService: ServerService, private readonly configService: ConfigService, private readonly eventAuthorizationService: EventAuthorizationService, private readonly stateService: StateService, @@ -37,6 +37,8 @@ export class FederationSDK { private readonly wellKnownService: WellKnownService, private readonly federationRequestService: FederationRequestService, private readonly federationService: FederationService, + private readonly signatureVerficationService: SignatureVerificationService, + private readonly keyService: KeyService, ) {} createDirectMessageRoom( @@ -119,10 +121,8 @@ export class FederationSDK { return this.eduService.sendTypingNotification(...args); } - getSignedServerKey( - ...args: Parameters - ) { - return this.serverService.getSignedServerKey(...args); + getSignedServerKey() { + return this.keyService.getOwnSignedServerKeyResponse(); } getConfig(config: K): AppConfig[K] { @@ -134,11 +134,17 @@ export class FederationSDK { } verifyRequestSignature( - ...args: Parameters< - typeof this.eventAuthorizationService.verifyRequestSignature - > + authorizationHeader: string, + method: string, + uri: string, + body?: Record, ) { - return this.eventAuthorizationService.verifyRequestSignature(...args); + return this.signatureVerficationService.verifyRequestSignature({ + authorizationHeader, + method, + uri, + body, + }); } joinUser(...args: Parameters) { diff --git a/packages/federation-sdk/src/services/config.service.ts b/packages/federation-sdk/src/services/config.service.ts index ac4e44727..a1ed626b7 100644 --- a/packages/federation-sdk/src/services/config.service.ts +++ b/packages/federation-sdk/src/services/config.service.ts @@ -1,11 +1,11 @@ -import { - SigningKey, - createLogger, - generateKeyPairsFromString, - toUnpaddedBase64, -} from '@rocket.chat/federation-core'; +import { createLogger } from '@rocket.chat/federation-core'; import { singleton } from 'tsyringe'; +import { + Signer, + fromBase64ToBytes, + loadEd25519SignerFromSeed, +} from '@rocket.chat/federation-crypto'; import { z } from 'zod'; export interface AppConfig { @@ -17,6 +17,7 @@ export interface AppConfig { keyRefreshInterval: number; signingKey?: string; timeout?: number; + // TODO: need this still? signingKeyPath?: string; media: { maxFileSize: number; @@ -82,7 +83,8 @@ export const AppConfigSchema = z.object({ export class ConfigService { private config: AppConfig = {} as AppConfig; private logger = createLogger('ConfigService'); - private serverKeys: SigningKey[] = []; + + private signer: Signer | undefined; setConfig(values: AppConfig) { try { @@ -122,34 +124,22 @@ export class ConfigService { } async getSigningKey() { + if (this.signer) { + return this.signer; + } + // If config contains a signing key, use it if (!this.config.signingKey) { throw new Error('Signing key is not configured'); } - if (!this.serverKeys.length) { - const signingKey = await generateKeyPairsFromString( - this.config.signingKey, - ); - this.serverKeys = [signingKey]; - } + const [, version, signingKey] = this.config.signingKey.split(' '); - return this.serverKeys; - } - - async getSigningKeyId(): Promise { - const signingKeys = await this.getSigningKey(); - const signingKey = signingKeys[0]; - return `${signingKey.algorithm}:${signingKey.version}` || 'ed25519:1'; - } - - async getSigningKeyBase64(): Promise { - const signingKeys = await this.getSigningKey(); - return toUnpaddedBase64(signingKeys[0].privateKey); - } + this.signer = await loadEd25519SignerFromSeed( + fromBase64ToBytes(signingKey), + version, + ); - async getPublicSigningKeyBase64(): Promise { - const signingKeys = await this.getSigningKey(); - return toUnpaddedBase64(signingKeys[0].publicKey); + return this.signer; } } diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index 4045bbc95..3d67260c5 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -13,7 +13,11 @@ import { delay, inject, singleton } from 'tsyringe'; import { UploadRepository } from '../repositories/upload.repository'; import { ConfigService } from './config.service'; import { EventService } from './event.service'; -import { ServerService } from './server.service'; +import { + FailedSignatureVerificationPreconditionError, + InvalidRequestSignatureError, + SignatureVerificationService, +} from './signature-verification.service'; import { StateService } from './state.service'; export class AclDeniedError extends Error { @@ -31,9 +35,9 @@ export class EventAuthorizationService { private readonly stateService: StateService, private readonly eventService: EventService, private readonly configService: ConfigService, - private readonly serverService: ServerService, @inject(delay(() => UploadRepository)) private readonly uploadRepository: UploadRepository, + private readonly signatureVerificationService: SignatureVerificationService, ) {} async authorizeEvent(event: Pdu, authEvents: Pdu[]): Promise { @@ -135,51 +139,22 @@ export class EventAuthorizationService { } try { - const { origin, destination, key, signature } = - extractSignaturesFromHeader(authorizationHeader); - - if ( - !origin || - !key || - !signature || - (destination && destination !== this.configService.serverName) - ) { - return; - } - - const [algorithm] = key.split(':'); - if (algorithm !== 'ed25519') { - return; - } - - const publicKey = await this.serverService.getPublicKey(origin, key); - if (!publicKey) { - this.logger.warn(`Could not fetch public key for ${origin}:${key}`); - return; - } - - const actualDestination = destination || this.configService.serverName; - const isValid = await validateAuthorizationHeader( - origin, - publicKey, - actualDestination, + return await this.signatureVerificationService.verifyRequestSignature({ + authorizationHeader, method, uri, - signature, body, - ); - if (!isValid) { - this.logger.warn(`Invalid signature from ${origin}`); + }); + } catch (error) { + if ( + error instanceof InvalidRequestSignatureError || + error instanceof FailedSignatureVerificationPreconditionError + ) { + this.logger.warn('Invalid request signature'); return; } - return origin; - } catch (error) { - this.logger.error({ - msg: 'Error verifying request signature', - err: error, - }); - return; + throw error; } } diff --git a/packages/federation-sdk/src/services/event.service.spec.ts b/packages/federation-sdk/src/services/event.service.spec.ts new file mode 100644 index 000000000..8f8f7306d --- /dev/null +++ b/packages/federation-sdk/src/services/event.service.spec.ts @@ -0,0 +1,335 @@ +import { + Mock, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from 'bun:test'; +import { BaseEDU } from '@rocket.chat/federation-core'; +import { + fromBase64ToBytes, + loadEd25519SignerFromSeed, +} from '@rocket.chat/federation-crypto'; +import { + EventID, + Pdu, + PersistentEventFactory, + RoomID, + UserID, +} from '@rocket.chat/federation-room'; +import { runIfMongoExists } from '../__mocks__/block-if-no-mongo'; +import { repositories } from '../__mocks__/repositories.spec'; +import { eventService } from '../__mocks__/services.spec'; +import type { ConfigService } from './config.service'; +import { StateService } from './state.service'; + +const event = { + auth_events: [ + '$Hvb-xPPDhTvlXZe2kMubgj8J7iUa5W7YvjTqMTffgUA', + '$Ulggyo4m1OlI08Z0jJDVeceigjSZP9SdEFVoAn9mEh8', + '$G2TzsvetG2YlHr20tZLHCCzOd-yxPa1jeFT8OU4_6kg', + '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs', + ] as EventID[], + content: { + avatar_url: null, + displayname: 'debdut1', + membership: 'join' as const, + }, + depth: 10, + hashes: { sha256: '6MnKSCFJy1fYf6ukILBEbqx2DkoaD1wRyKXhv689a0A' }, + origin: 'syn1.tunnel.dev.rocket.chat', + origin_server_ts: 1757328411218, + prev_events: ['$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs'] as EventID[], + room_id: '!VoUasOLSpcdtRbGHdT:syn2.tunnel.dev.rocket.chat' as RoomID, + sender: '@debdut1:syn1.tunnel.dev.rocket.chat' as UserID, + state_key: '@debdut1:syn1.tunnel.dev.rocket.chat' as UserID, + type: 'm.room.member' as const, + signatures: { + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'eJlvqxPWPe3u+BM4wOwID9YBlh/ZfVVxGYyA5WgpNs5Fe1+c36qrvCKHuXGGjfQoZFrHmZ3/GJw2pv5EvxCZAA', + }, + }, + unsigned: { + age: 1, + replaces_state: '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs' as EventID, + prev_content: { displayname: 'debdut1', membership: 'invite' }, + prev_sender: '@debdut:syn2.tunnel.dev.rocket.chat' as UserID, + }, +}; + +runIfMongoExists(() => + describe('EventService', async () => { + const { getHomeserverFinalAddress: originalServerDiscovery } = await import( + '../server-discovery/discovery' + ); + + const { fetch } = await import('@rocket.chat/federation-core'); + + // random server name for each run + let inboundServer = `localhost${Math.floor(Math.random() * 10000).toString()}`; + + type FetchJson = Awaited>['json']; + + const fetchJsonMock: Mock = mock(() => Promise.resolve()); + + beforeEach(async () => { + await mock.module('../server-discovery/discovery', () => ({ + // this mock doesn't matter, or doesn't change, we just need to skip actual server discovery + // and mock the /key/v2/server responses + getHomeserverFinalAddress: async (..._args: any[]) => [ + 'https://127.0.0.1', + {}, + ], + })); + + await mock.module('@rocket.chat/federation-core', () => ({ + fetch: async (..._args: any[]) => { + return { + ok: true, + status: 200, + json: fetchJsonMock as unknown as FetchJson, + } as Response; + }, + })); + }); + + afterEach(async () => { + await mock.module('@rocket.chat/federation-core', () => ({ fetch })); + await mock.module('../server-discovery/discovery', () => ({ + getHomeserverFinalAddress: originalServerDiscovery, + })); + mock.restore(); + }); + + describe('processIncomingTransaction', async () => { + it('should fail basic malformed payloads (sanity checks)', async () => { + expect( + eventService.processIncomingTransaction({ + origin: 'test.local', + // @ts-expect-error + pdus: {}, + }), + ).rejects.toThrowError(/pdus must be an array/); + expect( + eventService.processIncomingTransaction({ + origin: 'test.local', + pdus: [], + // @ts-expect-error + edus: {}, + }), + ).rejects.toThrowError(/edus must be an array/); + + expect( + eventService.processIncomingTransaction({ + origin: 'test.local', + pdus: Array.from({ length: 51 }).fill({}) as Pdu[], + edus: [], + }), + ).rejects.toThrowError(/too-many-events/); + + expect( + eventService.processIncomingTransaction({ + origin: 'test.local', + edus: Array.from({ length: 101 }).fill({}) as BaseEDU[], + pdus: [], + }), + ).rejects.toThrowError(/too-many-events/); + + // NOTE(deb): should also check the happy path but not running all function tests so skipping that + }); + }); + + describe('_validateHashAndSignatures', async () => { + const roomVersion = '10' as const; + + // to build events with different signatures, creating new instance of stateService here + const newSeed = 'JFU4ln6/aSnXWF5EY9m7N9Z/MDUHRLt9C+Z6Vv34Ims'; + const version = 'xxx'; + const signer = await loadEd25519SignerFromSeed( + fromBase64ToBytes(newSeed), + version, + ); + + let stateService: StateService; + + beforeEach(async () => { + inboundServer = `localhost${Math.floor(Math.random() * 10000).toString()}`; + const newConfig = { + getSigningKey: async () => signer, + serverName: inboundServer, + } as unknown as ConfigService; + + stateService = new StateService( + repositories.states, + repositories.events, + newConfig, + ); + }); + // this should be changed as needed + const originalKeyResponse = { + old_verify_keys: {}, + server_name: inboundServer, + signatures: {}, + verify_keys: { + [signer.id]: { + key: Buffer.from(signer.getPublicKey()).toString('base64'), + }, + }, + valid_until_ts: Date.now() + 100000, + }; + + it('should fail to fetch room informatin of unknown room, sanity check for mock loading', async () => { + expect(stateService.getRoomVersion('abc123')).rejects.toThrowError( + 'Room abc123 does not exist', + ); + }); + + // sanity check + it('should sign events with new keys', async () => { + const pdu = PersistentEventFactory.createFromRawEvent( + event, + roomVersion, + ); + + await stateService.signEvent(pdu); + + expect(pdu.event.signatures[inboundServer]).toBeDefined(); + expect(pdu.event.signatures?.[inboundServer]).toHaveProperty( + `ed25519:${version}`, + ); + + // now this stateService will pretend to be the other homeserver + }); + + it('should fail if event has an invalid hash', async () => { + const eventCopy = JSON.parse(JSON.stringify(event)); + eventCopy.content.avatar_url = undefined; + + await expect( + eventService.validateHashAndSignatures(eventCopy, roomVersion), + ).rejects.toThrowError(/M_INVALID_HASH/); + }); + + it('should successfully validate hash and signature (happy path)', async () => { + // 1. create an event + const pdu = PersistentEventFactory.newCreateEvent( + `@creator:${inboundServer}` as UserID, + roomVersion, + ); + + await stateService.signEvent(pdu); + + // now OUR event service gets this event + // to allow fetchign the key we mock + fetchJsonMock.mockReturnValue(Promise.resolve(originalKeyResponse)); + + eventService + .validateHashAndSignatures(pdu.event, roomVersion) + .catch(console.error); + + await expect( + eventService.validateHashAndSignatures(pdu.event, roomVersion), + ).resolves.toHaveProperty('eventId', pdu.eventId); + }); + + it('should fail if signed by a key expired at the point of event creation', async () => { + const pdu = PersistentEventFactory.newCreateEvent( + `@creator:${inboundServer}` as UserID, + roomVersion, + ); + + // event created NOW, so will we sign + await stateService.signEvent(pdu); + + // but the key is expired + const expiredKeyResponse = { + ...originalKeyResponse, + valid_until_ts: Date.now() - 10000000, + }; + fetchJsonMock.mockReturnValue(Promise.resolve(expiredKeyResponse)); + + await expect( + eventService.validateHashAndSignatures(pdu.event, roomVersion), + ).rejects.toThrow(); + + // not enough, now we add the key to old_verify_keys + const oldKeyResponse = { + ...originalKeyResponse, + verify_keys: {}, + old_verify_keys: { + [signer.id]: { + key: Buffer.from(signer.getPublicKey()).toString('base64'), + expired_ts: Date.now() - 1000, + }, + }, + }; + fetchJsonMock.mockReturnValue(Promise.resolve(oldKeyResponse)); + + // need to invalidate the cache though + // new room id does it + const pdu2 = PersistentEventFactory.newCreateEvent( + `@creator:${inboundServer}` as UserID, + roomVersion, + ); + + // event created NOW, so will we sign + await stateService.signEvent(pdu2); + + await expect( + eventService.validateHashAndSignatures(pdu2.event, roomVersion), + ).rejects.toThrow(); + }); + + it('should fail if signed by an unknown key', async () => { + // 1. create an event + const pdu = PersistentEventFactory.newCreateEvent( + `@creator:${inboundServer}` as UserID, + roomVersion, + ); + + await stateService.signEvent(pdu); + + // don't send any keys + fetchJsonMock.mockReturnValue( + Promise.resolve({ ...originalKeyResponse, verify_keys: {} }), + ); + + await expect( + eventService.validateHashAndSignatures(pdu.event, roomVersion), + ).rejects.toThrow(); + }); + + it('should pass if signed by an old key', async () => { + const pdu = PersistentEventFactory.newCreateEvent( + `@creator:${inboundServer}` as UserID, + roomVersion, + ); + + (pdu as any).rawEvent.origin_server_ts -= 2000; // slightly older event + + // event created NOW, so will we sign + await stateService.signEvent(pdu); + + // key is expired but valid at event time + const oldKeyResponse = { + ...originalKeyResponse, + verify_keys: {}, + old_verify_keys: { + [signer.id]: { + key: Buffer.from(signer.getPublicKey()).toString('base64'), + expired_ts: pdu.originServerTs + 1, + }, + }, + }; + fetchJsonMock.mockReturnValue(Promise.resolve(oldKeyResponse)); + + await expect( + eventService.validateHashAndSignatures(pdu.event, roomVersion), + ).resolves.toHaveProperty('eventId', pdu.eventId); + }); + }); + }), +); diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index bef005f55..67bce6299 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -11,15 +11,14 @@ import { generateId } from '@rocket.chat/federation-core'; import type { EventStore } from '@rocket.chat/federation-core'; import { pruneEventDict } from '@rocket.chat/federation-core'; -import { checkSignAndHashes } from '@rocket.chat/federation-core'; import { createLogger } from '@rocket.chat/federation-core'; import { type EventID, type Pdu, type PduForType, type PduType, + PersistentEventBase, PersistentEventFactory, - RoomID, RoomVersion, getAuthChain, } from '@rocket.chat/federation-room'; @@ -32,7 +31,8 @@ import { LockRepository } from '../repositories/lock.repository'; import { eventSchemas } from '../utils/event-schemas'; import { ConfigService } from './config.service'; import { EventEmitterService } from './event-emitter.service'; -import { ServerService } from './server.service'; +import { KeyService, OldVerifierKey } from './key.service'; +import { SignatureVerificationService } from './signature-verification.service'; import { StateService } from './state.service'; export interface AuthEventParams { @@ -50,8 +50,9 @@ export class EventService { private readonly configService: ConfigService, private readonly stagingAreaQueue: StagingAreaQueue, private readonly stateService: StateService, - private readonly serverService: ServerService, private readonly eventEmitterService: EventEmitterService, + private readonly keyService: KeyService, + private readonly signatureVerificationService: SignatureVerificationService, @inject(delay(() => EventRepository)) private readonly eventRepository: EventRepository, @inject(delay(() => EventStagingRepository)) @@ -160,26 +161,33 @@ export class EventService { eventsByRoomId.get(roomId)?.push(event); } - const roomIdToRoomVersionmap = new Map(); - const getRoomVersion = async (roomId: RoomID) => { - if (roomIdToRoomVersionmap.has(roomId)) { - return roomIdToRoomVersionmap.get(roomId) as RoomVersion; + const roomVersionByRoomId = new Map(); + const getRoomVersion = async (roomId: string): Promise => { + if (roomVersionByRoomId.has(roomId)) { + return roomVersionByRoomId.get(roomId) as RoomVersion; } - const roomVersion = await this.getRoomVersion({ room_id: roomId }); - - roomIdToRoomVersionmap.set(roomId, roomVersion); + const roomVersion = await this.stateService.getRoomVersion(roomId); + if (roomVersion) { + roomVersionByRoomId.set(roomId, roomVersion); + return roomVersion; + } - return roomVersion; + throw new Error('M_UNKNOWN_ROOM_VERSION'); }; // process each room's events in parallel // TODO implement a concurrency limit await Promise.all( Array.from(eventsByRoomId.entries()).map(async ([roomId, events]) => { + // TODO: need to validate using zod schemas + + const roomVersion = await getRoomVersion(roomId); + for await (const event of events) { + let pdu: PersistentEventBase; try { - await this.validateEvent(event); + pdu = await this.validateEvent(event, roomVersion); } catch (err) { this.logger.error({ msg: 'Event validation failed', @@ -190,19 +198,10 @@ export class EventService { continue; } - const roomVersion = await getRoomVersion(event.room_id); - - const pdu = PersistentEventFactory.createFromRawEvent( - event, - roomVersion, - ); - - const eventId = pdu.eventId; - - const existing = await this.eventRepository.findById(eventId); + const existing = await this.eventRepository.findById(pdu.eventId); if (existing) { this.logger.info( - `Ignoring received event ${eventId} which we have already seen`, + `Ignoring received event ${pdu.eventId} which we have already seen`, ); // TODO we may need to check if an event is an outlier and re-process it @@ -210,7 +209,7 @@ export class EventService { } // save the event as staged to be processed - await this.eventStagingRepository.create(eventId, origin, event); + await this.eventStagingRepository.create(pdu.eventId, origin, event); // acquire a lock for processing the event const lock = await this.lockRepository.getLock( @@ -230,27 +229,19 @@ export class EventService { } }), ); - } - - private async validateEvent(event: Pdu): Promise { - const roomVersion = await this.getRoomVersion(event); - if (!roomVersion) { - throw new Error('M_UNKNOWN_ROOM_VERSION'); - } - if ( - event.type === 'm.room.member' && - event.content.membership === 'invite' && - 'third_party_invite' in event.content - ) { - throw new Error('Third party invites are not supported'); - } + // don't want the cache to indefinitely grow, nor cause stale keys to stay + // once a transaction is dfone, we can clear the cache + this.cachedVerifierKey.clear(); + } - const origin = event.sender.split(':').pop(); - if (!origin) { - throw new Error('Event sender is missing domain'); - } + private cachedVerifierKey: Map<`${string}:${string}`, OldVerifierKey> = + new Map(); + private async validateEvent( + event: Pdu, + roomVersion: RoomVersion, + ): Promise { const eventSchema = this.getEventSchema(roomVersion, event.type); const validationResult = eventSchema.safeParse(event); @@ -278,9 +269,83 @@ export class EventService { throw new Error('M_MISSING_SIGNATURES_OR_HASHES'); } - await checkSignAndHashes(event, origin, (origin, key) => { - return this.serverService.getPublicKey(origin, key); - }); + return this.validateHashAndSignatures(event, roomVersion); + } + + public async validateHashAndSignatures( + event: Pdu, + roomVersion: RoomVersion, + ): Promise { + const pdu = PersistentEventFactory.createFromRawEvent(event, roomVersion); + + const expectedHash = event.hashes.sha256; + + const hash = pdu.getContentHashString(); + + if (expectedHash !== hash) { + this.logger.error( + { event: pdu.event, expectedHash, hash }, + 'content hash validation failed', + ); + throw new Error('M_INVALID_HASH'); + } + + const keysRequired = pdu.getOriginKeys(); + + this.logger.debug( + { keys: keysRequired, eventId: pdu.eventId }, + 'Keys required to verify event', + ); + + for (const keyId of keysRequired) { + const cacheKey = `${pdu.roomId}:${keyId}` as const; + const verifier = this.cachedVerifierKey.get(cacheKey); + if (verifier) { + if (this.keyService.isVerifierAllowedToCheckEvent(pdu, verifier)) { + this.logger.debug( + { + id: verifier.key.id, + }, + 'using cached verifier', + ); + await this.signatureVerificationService.verifyEventSignature( + pdu, + verifier.key, + ); + + return pdu; + } + } + } + + this.logger.info( + { eventId: pdu.eventId }, + 'keys not in cache, fetching from KeyService', + ); + + const requiredVerifier = + await this.keyService.getRequiredVerifierForEvent(pdu); + + this.logger.debug( + { + eventId: pdu.eventId, + verifier: { + id: requiredVerifier.key.id, + expiredAt: requiredVerifier.expiredAt.toISOString(), + }, + }, + 'verifier required for pdu validation', + ); + + const cacheKey = `${pdu.roomId}:${requiredVerifier.key.id}` as const; + + this.cachedVerifierKey.set(cacheKey, requiredVerifier); + + await this.signatureVerificationService.verifyEventSignature( + pdu, + requiredVerifier.key, + ); + return pdu; } private async processIncomingEDUs(edus: BaseEDU[]): Promise { @@ -451,13 +516,6 @@ export class EventService { return parts.length > 1 ? parts[1] : ''; } - private async getRoomVersion(event: Pick) { - return ( - this.stateService.getRoomVersion(event.room_id) || - PersistentEventFactory.defaultRoomVersion - ); - } - private getEventSchema(roomVersion: string, eventType: string): z.ZodSchema { const versionSchemas = eventSchemas[roomVersion]; if (!versionSchemas) { diff --git a/packages/federation-sdk/src/services/federation-request.service.spec.ts b/packages/federation-sdk/src/services/federation-request.service.spec.ts index 83a056402..8e2e8d61d 100644 --- a/packages/federation-sdk/src/services/federation-request.service.spec.ts +++ b/packages/federation-sdk/src/services/federation-request.service.spec.ts @@ -9,27 +9,28 @@ import { spyOn, } from 'bun:test'; import * as core from '@rocket.chat/federation-core'; -import * as nacl from 'tweetnacl'; +import { + fromBase64ToBytes, + loadEd25519SignerFromSeed, +} from '@rocket.chat/federation-crypto'; import { ConfigService } from './config.service'; import { FederationRequestService } from './federation-request.service'; +const signingKeyContent = + 'ed25519 a_FAET FC6cwY3DNmHo3B7GRugaHNyXz+TkBRVx8RvQH0kSZ04'; + +const origin = 'syn1.tunnel.dev.rocket.chat'; + +const destination = 'syn2.tunnel.dev.rocket.chat'; + describe('FederationRequestService', async () => { let service: FederationRequestService; let configService: ConfigService; - const mockServerName = 'example.com'; - const mockSigningKey = 'aGVsbG93b3JsZA=='; - const mockSigningKeyId = 'ed25519:1'; - - const mockKeyPair = { - publicKey: new Uint8Array([1, 2, 3]), - secretKey: new Uint8Array([4, 5, 6]), - }; - const mockDiscoveryResult = [ - 'https://target.example.com:443' as const, + `https://${destination}:443` as const, { - Host: 'target.example.com', + Host: destination, }, ]; @@ -51,7 +52,7 @@ describe('FederationRequestService', async () => { json: async () => ({ result: 'success' }), text: async () => '{"result":"success"}', multipart: async () => null, - } as Response; + }; }, })); @@ -65,42 +66,23 @@ describe('FederationRequestService', async () => { })); }); - const mockSignature = new Uint8Array([7, 8, 9]); - - const mockSignedJson = { - content: 'test', - signatures: { - 'example.com': { - 'ed25519:1': 'abcdef', - }, - }, - }; - - const mockAuthHeaders = - 'X-Matrix origin="example.com",destination="target.example.com",key="ed25519:1",sig="xyz123"'; - beforeEach(() => { - spyOn(nacl.sign.keyPair, 'fromSecretKey').mockReturnValue(mockKeyPair); - spyOn(nacl.sign, 'detached').mockReturnValue(mockSignature); - - spyOn(core, 'extractURIfromURL').mockReturnValue('/test/path?query=value'); - spyOn(core, 'authorizationHeaders').mockResolvedValue(mockAuthHeaders); - spyOn(core, 'signJson').mockResolvedValue(mockSignedJson); - spyOn(core, 'computeAndMergeHash').mockImplementation( - (obj: unknown) => obj, - ); - configService = { getConfig: (key: string) => { if (key === 'serverName') { - return mockServerName; + return origin; } throw new Error(`Unknown config key: ${key}`); }, - serverName: mockServerName, - getSigningKeyBase64: async () => mockSigningKey, - getSigningKeyId: async () => mockSigningKeyId, - } as ConfigService; + serverName: origin, + getSigningKey: async () => { + const [, version, signingKey] = signingKeyContent.split(' '); + return loadEd25519SignerFromSeed( + fromBase64ToBytes(signingKey), + version, + ); + }, + } as unknown as ConfigService; service = new FederationRequestService(configService); }); @@ -110,260 +92,89 @@ describe('FederationRequestService', async () => { }); describe('makeSignedRequest', () => { - it('should make a successful signed request without body', async () => { + it('should make a successful signed request with a body', async () => { + const transactionBody = { + edus: [ + { + content: { + push: [ + { + last_active_ago: 561472, + presence: 'unavailable', + user_id: '@debdut1:syn1.tunnel.dev.rocket.chat', + }, + ], + }, + edu_type: 'm.presence', + }, + ], + origin: 'syn1.tunnel.dev.rocket.chat', + origin_server_ts: 1757329414731, + pdus: [], + }; + + // PUT /_matrix/federation/v1/send/1757328278684 HTTP/1.1 + + const uri = '/_matrix/federation/v1/send/1757328278684'; + + const method = 'PUT'; const fetchSpy = spyOn(core, 'fetch'); - const result = await service.makeSignedRequest({ - method: 'GET', - domain: 'target.example.com', - uri: '/test/path', + await service.makeSignedRequest({ + method, + // Host: syn2.tunnel.dev.rocket.chat + domain: destination, + uri, + body: transactionBody, }); - expect(configService.serverName).toBe(mockServerName); - expect(await configService.getSigningKeyBase64()).toBe(mockSigningKey); - expect(await configService.getSigningKeyId()).toBe(mockSigningKeyId); - expect(configService.serverName).toBe(mockServerName); - expect(await configService.getSigningKeyBase64()).toBe(mockSigningKey); - expect(await configService.getSigningKeyId()).toBe(mockSigningKeyId); - - expect(nacl.sign.keyPair.fromSecretKey).toHaveBeenCalled(); - expect(fetchSpy).toHaveBeenCalledWith( - new URL('https://target.example.com/test/path'), + new URL(`https://${destination}${uri}`), expect.objectContaining({ - method: 'GET', + method: 'PUT', headers: expect.objectContaining({ - Authorization: mockAuthHeaders, - Host: 'target.example.com', + // Authorization: X-Matrix origin="syn1.tunnel.dev.rocket.chat",key="ed25519:a_FAET",sig="+MRd0eKdc/3T7mS7ZR+ltpOiN7RBXgfxTWWYLejy5gBRXG717aXHPCDm044D10kgqQvs2HqR3MdPEIx+2a0nDg",destination="syn2.tunnel.dev.rocket.chat" + Authorization: + 'X-Matrix origin="syn1.tunnel.dev.rocket.chat",destination="syn2.tunnel.dev.rocket.chat",key="ed25519:a_FAET",sig="+MRd0eKdc/3T7mS7ZR+ltpOiN7RBXgfxTWWYLejy5gBRXG717aXHPCDm044D10kgqQvs2HqR3MdPEIx+2a0nDg"', + Host: destination, }), }), ); - - expect(result.ok).toBe(true); - expect(result.status).toBe(200); - expect(await result.json()).toEqual({ result: 'success' }); }); - it('should make a successful signed request with body', async () => { - const fetchSpy = spyOn(core, 'fetch'); + it('should make a successful signed GET request', async () => { + /* + GET /_matrix/federation/v1/make_join/%21VoUasOLSpcdtRbGHdT%3Asyn2.tunnel.dev.rocket.chat/%40debdut1%3Asyn1.tunnel.dev.rocket.chat?ver=1&ver=2&ver=3&ver=4&ver=5&ver=6&ver=7&ver=8&ver=9&ver=10&ver=11&ver=org.matrix.msc3757.10&ver=org.matrix.msc3757.11 HTTP/1.1 + Host: syn2.tunnel.dev.rocket.chat + User-Agent: Synapse/1.132.0 + Authorization: X-Matrix origin="syn1.tunnel.dev.rocket.chat",key="ed25519:a_FAET",sig="PNSix5GF9IquSmMOj+yx6rPDEZwcI1KrAQ6TzspAQyrwapQuFYXfhQmxoxKA1X7PUhUGSmQZUWrO4VInIpwwCA",destination="syn2.tunnel.dev.rocket.chat" + */ + const uri = + '/_matrix/federation/v1/make_join/%21VoUasOLSpcdtRbGHdT%3Asyn2.tunnel.dev.rocket.chat/%40debdut1%3Asyn1.tunnel.dev.rocket.chat'; + const method = 'GET'; + const queryString = + 'ver=1&ver=2&ver=3&ver=4&ver=5&ver=6&ver=7&ver=8&ver=9&ver=10&ver=11&ver=org.matrix.msc3757.10&ver=org.matrix.msc3757.11'; - const mockBody = { key: 'value' }; + const fetchSpy = spyOn(core, 'fetch'); - const result = await service.makeSignedRequest({ - method: 'POST', - domain: 'target.example.com', - uri: '/test/path', - body: mockBody, + await service.makeSignedRequest({ + method, + domain: destination, + uri, + queryString, }); - expect(core.signJson).toHaveBeenCalledWith( - expect.objectContaining({ key: 'value', signatures: {} }), - expect.any(Object), - mockServerName, - ); - - expect(core.authorizationHeaders).toHaveBeenCalledWith( - mockServerName, - expect.any(Object), - 'target.example.com', - 'POST', - '/test/path?query=value', - mockSignedJson, - ); - expect(fetchSpy).toHaveBeenCalledWith( - new URL('https://target.example.com/test/path'), + new URL(`https://${destination}${uri}?${queryString}`), expect.objectContaining({ - method: 'POST', - body: JSON.stringify(mockSignedJson), + method: 'GET', + headers: expect.objectContaining({ + Authorization: + 'X-Matrix origin="syn1.tunnel.dev.rocket.chat",destination="syn2.tunnel.dev.rocket.chat",key="ed25519:a_FAET",sig="PNSix5GF9IquSmMOj+yx6rPDEZwcI1KrAQ6TzspAQyrwapQuFYXfhQmxoxKA1X7PUhUGSmQZUWrO4VInIpwwCA"', + Host: destination, + }), }), ); - - expect(result.ok).toBe(true); - expect(result.status).toBe(200); - expect(await result.json()).toEqual({ result: 'success' }); - }); - - it('should make a signed request with query parameters', async () => { - const fetchSpy = spyOn(core, 'fetch'); - - const result = await service.makeSignedRequest({ - method: 'GET', - domain: 'target.example.com', - uri: '/test/path', - queryString: 'param1=value1¶m2=value2', - }); - - expect(fetchSpy).toHaveBeenCalledWith( - new URL( - 'https://target.example.com/test/path?param1=value1¶m2=value2', - ), - expect.any(Object), - ); - - expect(result.ok).toBe(true); - expect(result.status).toBe(200); - expect(await result.json()).toEqual({ result: 'success' }); - }); - - it('should handle fetch errors properly', async () => { - globalThis.fetch = Object.assign( - async () => { - return { - ok: false, - status: 404, - text: async () => 'Not Found', - multipart: async () => null, - } as Response; - }, - { preconnect: () => {} }, - ) as typeof fetch; - - try { - await service.makeSignedRequest({ - method: 'GET', - domain: 'target.example.com', - uri: '/test/path', - }); - } catch (error: unknown) { - if (error instanceof Error) { - expect(error.message).toContain( - 'Federation request failed: 404 Not Found', - ); - } else { - throw error; - } - } - }); - - it('should handle JSON error responses properly', async () => { - globalThis.fetch = Object.assign( - async () => { - return { - ok: false, - status: 400, - text: async () => - '{"error":"Bad Request","code":"M_INVALID_PARAM"}', - multipart: async () => null, - } as Response; - }, - { preconnect: () => {} }, - ) as typeof fetch; - - try { - await service.makeSignedRequest({ - method: 'GET', - domain: 'target.example.com', - uri: '/test/path', - }); - } catch (error: unknown) { - if (error instanceof Error) { - expect(error.message).toContain( - 'Federation request failed: 400 {"error":"Bad Request","code":"M_INVALID_PARAM"}', - ); - } else { - throw error; - } - } - }); - - it('should handle network errors properly', async () => { - globalThis.fetch = Object.assign( - async () => { - throw new Error('Network Error'); - }, - { preconnect: () => {} }, - ) as typeof fetch; - - try { - await service.makeSignedRequest({ - method: 'GET', - domain: 'target.example.com', - uri: '/test/path', - }); - } catch (error: unknown) { - if (error instanceof Error) { - expect(error.message).toBe('Network Error'); - } else { - throw error; - } - } - }); - }); - - describe('convenience methods', () => { - it('should call makeSignedRequest with correct parameters for GET', async () => { - const makeSignedRequestSpy = spyOn( - service, - 'makeSignedRequest', - ).mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ result: 'success' }), - text: async () => '{"result":"success"}', - multipart: async () => null, - }); - - await service.get('target.example.com', '/api/resource', { - filter: 'active', - }); - - expect(makeSignedRequestSpy).toHaveBeenCalledWith({ - method: 'GET', - domain: 'target.example.com', - uri: '/api/resource', - queryString: 'filter=active', - }); - }); - - it('should call makeSignedRequest with correct parameters for POST', async () => { - const makeSignedRequestSpy = spyOn( - service, - 'makeSignedRequest', - ).mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ result: 'success' }), - text: async () => '{"result":"success"}', - multipart: async () => null, - }); - - const body = { data: 'example' }; - await service.post('target.example.com', '/api/resource', body, { - version: '1', - }); - - expect(makeSignedRequestSpy).toHaveBeenCalledWith({ - method: 'POST', - domain: 'target.example.com', - uri: '/api/resource', - body, - queryString: 'version=1', - }); - }); - - it('should call makeSignedRequest with correct parameters for PUT', async () => { - const makeSignedRequestSpy = spyOn( - service, - 'makeSignedRequest', - ).mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ result: 'success' }), - text: async () => '{"result":"success"}', - multipart: async () => null, - }); - - const body = { data: 'updated' }; - await service.put('target.example.com', '/api/resource/123', body); - - expect(makeSignedRequestSpy).toHaveBeenCalledWith({ - method: 'PUT', - domain: 'target.example.com', - uri: '/api/resource/123', - body, - queryString: '', - }); }); }); @@ -377,7 +188,7 @@ describe('FederationRequestService', async () => { ok: true, status: 200, multipart: async () => ({ content: mockBuffer }), - }); + } as any); const result = await service.requestBinaryData( 'GET', @@ -403,7 +214,7 @@ describe('FederationRequestService', async () => { ok: true, status: 200, multipart: async () => ({ content: mockBuffer }), - }); + } as any); const result = await service.requestBinaryData( 'GET', diff --git a/packages/federation-sdk/src/services/federation-request.service.ts b/packages/federation-sdk/src/services/federation-request.service.ts index 928e4765a..e74a22e73 100644 --- a/packages/federation-sdk/src/services/federation-request.service.ts +++ b/packages/federation-sdk/src/services/federation-request.service.ts @@ -1,19 +1,11 @@ import type { FetchResponse, MultipartResult, - SigningKey, -} from '@rocket.chat/federation-core'; -import { - EncryptionValidAlgorithm, - authorizationHeaders, - computeAndMergeHash, - createLogger, - extractURIfromURL, - fetch, - signJson, } from '@rocket.chat/federation-core'; +import { createLogger } from '@rocket.chat/federation-core'; +import { fetch } from '@rocket.chat/federation-core'; +import { signJson } from '@rocket.chat/federation-crypto'; import { singleton } from 'tsyringe'; -import * as nacl from 'tweetnacl'; import { getHomeserverFinalAddress } from '../server-discovery/discovery'; import { ConfigService } from './config.service'; @@ -33,6 +25,7 @@ export class FederationRequestService { constructor(private readonly configService: ConfigService) {} + // Implements SPEC: https://spec.matrix.org/v1.12/server-server-api/#request-authentication async makeSignedRequest({ method, domain, @@ -40,60 +33,52 @@ export class FederationRequestService { body, queryString, }: SignedRequest): Promise> { - const serverName = this.configService.getConfig('serverName'); - const signingKeyBase64 = await this.configService.getSigningKeyBase64(); - const signingKeyId = await this.configService.getSigningKeyId(); - const privateKeyBytes = Buffer.from(signingKeyBase64, 'base64'); - const keyPair = nacl.sign.keyPair.fromSecretKey(privateKeyBytes); - - const signingKey: SigningKey = { - algorithm: EncryptionValidAlgorithm.ed25519, - version: signingKeyId.split(':')[1] || '1', - privateKey: keyPair.secretKey, - publicKey: keyPair.publicKey, - sign: async (data: Uint8Array) => - nacl.sign.detached(data, keyPair.secretKey), - }; + const signer = await this.configService.getSigningKey(); const [address, discoveryHeaders] = await getHomeserverFinalAddress( domain, this.logger, ); + const origin = this.configService.serverName; + const url = new URL(`${address}${uri}`); if (queryString) { url.search = queryString; } - this.logger.debug(`Making ${method} request to ${url.toString()}`); + // build the auth request + const request = { + method, + uri: url.pathname + url.search, + origin, + destination: domain, + ...(body && { content: body }), + }; - let signedBody: Record | undefined; - if (body) { - signedBody = await signJson( - body.hashes ? body : computeAndMergeHash({ ...body, signatures: {} }), - signingKey, - serverName, - ); - } + const requestSignature = await signJson(request, signer); - const auth = await authorizationHeaders( - serverName, - signingKey, - domain, - method, - extractURIfromURL(url), - signedBody, - ); + const authorizationHeaderValue = `X-Matrix origin="${origin}",destination="${domain}",key="${signer.id}",sig="${requestSignature}"`; const headers = { - Authorization: auth, + Authorization: authorizationHeaderValue, ...discoveryHeaders, - ...(signedBody && { 'Content-Type': 'application/json' }), + ...(body && { 'Content-Type': 'application/json' }), }; + this.logger.debug( + { + method, + body: body, + headers, + url: url.toString(), + }, + 'making http request', + ); + const response = await fetch(url, { method, - ...(signedBody && { body: JSON.stringify(signedBody) }), + ...(body && { body: JSON.stringify(body) }), headers, }); @@ -111,7 +96,7 @@ export class FederationRequestService { let errorDetail = errorText; try { - errorDetail = JSON.stringify(JSON.parse(errorText || '')); + errorDetail = JSON.stringify(JSON.parse(errorText)); } catch { /* use raw text if parsing fails */ } diff --git a/packages/federation-sdk/src/services/key.service.spec.ts b/packages/federation-sdk/src/services/key.service.spec.ts new file mode 100644 index 000000000..1e993a9e6 --- /dev/null +++ b/packages/federation-sdk/src/services/key.service.spec.ts @@ -0,0 +1,217 @@ +import { verifyJsonSignature } from '@rocket.chat/federation-crypto'; + +import { Mock, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach } from 'node:test'; + +import { runIfMongoExists } from '../__mocks__/block-if-no-mongo'; +import { keyService } from '../__mocks__/services.spec'; +import { signer } from '../__mocks__/singer.spec'; + +runIfMongoExists(() => + describe('KeyService', async () => { + // fetch mocking + const { fetch } = await import('@rocket.chat/federation-core'); + + let inboundServer = ''; // skips server discovery + + beforeEach(async () => { + await mock.module('@rocket.chat/federation-core', () => ({ + fetch: async (..._args: any[]) => { + return { + ok: true, + status: 200, + json: fetchJsonMock as unknown as FetchJson, + } as Response; + }, + })); + inboundServer = `localhost:${Math.floor(Math.random() * 10000)}`; + }); + + afterEach(async () => { + await mock.module('@rocket.chat/federation-core', () => ({ fetch })); + mock.restore(); + }); + + type FetchJson = Awaited>['json']; + + const fetchJsonMock: Mock = mock(() => Promise.resolve()); + const publicKey = Buffer.from(signer.getPublicKey()).toString('base64'); + + it('should act as a notary server', async () => { + fetchJsonMock.mockReturnValue( + Promise.resolve({ + server_name: inboundServer, + valid_until_ts: Date.now() + 100000, + verify_keys: { + 'ed25519:0': { key: publicKey }, + }, + signatures: { + [inboundServer]: { + 'ed25519:0': 'c2lnbmF0dXJl', // dummy signature, not verified in this test + }, + }, + old_verify_keys: {}, + }), + ); + + const response = await keyService.handleQuery({ + server_keys: { [inboundServer]: {} }, + }); + + expect(response).toHaveProperty('server_keys'); + expect(response.server_keys).toBeArray(); + + const key = response.server_keys.find( + (k: unknown) => + typeof k === 'object' && + k !== null && + 'server_name' in k && + k.server_name === inboundServer && + 'verify_keys' in k && + typeof k.verify_keys === 'object' && + k.verify_keys !== null && + 'ed25519:0' in k.verify_keys && + k.verify_keys['ed25519:0'], + ); + + expect(key).toBeDefined(); + + expect(key).toHaveProperty('verify_keys'); + expect(key.verify_keys).toHaveProperty('ed25519:0'); + expect(key.verify_keys['ed25519:0']).toHaveProperty('key'); + expect(key.verify_keys['ed25519:0'].key).toBeString(); + expect(key.verify_keys['ed25519:0'].key).toBe(publicKey); + + const signature = key?.signatures?.['test.local']; + + expect(signature).toBeDefined(); + expect(Object.keys(signature).length).toBeGreaterThanOrEqual(1); + + const signatureValue = signature?.['ed25519:0']; + + expect(signatureValue).toBeDefined(); + + const { signatures, ...rest } = key; + + expect( + verifyJsonSignature(rest, signatureValue, signer), + ).resolves.toBeUndefined(); + }); + + it('should return an expired key if it can not find any others', async () => { + const keyId0 = 'ed25519:0'; + // -24 hours + const expiresAt = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const keyResponse0 = { + server_name: inboundServer, + valid_until_ts: expiresAt.getTime(), + verify_keys: { + [keyId0]: { key: publicKey }, + }, + old_verify_keys: {}, + signatures: {}, + }; + + fetchJsonMock.mockReturnValue(Promise.resolve(keyResponse0)); + + // fills the database with an expired key + await keyService.handleQuery({ + server_keys: { [inboundServer]: { [keyId0]: {} } }, + }); + + // make a second request + await keyService.handleQuery({ + server_keys: { + [inboundServer]: { + [keyId0]: { + minimum_valid_until_ts: expiresAt.getTime() + 1000, + }, + }, + }, + }); + + const { server_keys: serverKeys } = await keyService.handleQuery({ + server_keys: { + [inboundServer]: { + [keyId0]: { + minimum_valid_until_ts: expiresAt.getTime() + 1000, + }, + }, + }, + }); + + expect(serverKeys).toBeArray(); + expect(serverKeys[0]).toHaveProperty('server_name', inboundServer); + expect(serverKeys[0].verify_keys).toHaveProperty(keyId0); + expect(serverKeys[0].valid_until_ts).toBe(expiresAt.getTime()); + }); + + it('must not overwrite a valid key with a spurious result from the origin server', async () => { + const keyid1 = 'ed25519:1'; + // -24 houts + const expiresAt = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const keyResponse1 = { + server_name: inboundServer, + valid_until_ts: expiresAt.getTime(), + verify_keys: { + [keyid1]: { key: publicKey }, + }, + old_verify_keys: {}, + signatures: {}, + }; + + fetchJsonMock.mockReturnValue(Promise.resolve(keyResponse1)); + + const response1 = await keyService.handleQuery({ + server_keys: { [inboundServer]: { [keyid1]: {} } }, + }); + + expect(response1.server_keys[0]).toHaveProperty( + 'server_name', + inboundServer, + ); + expect(response1.server_keys[0].verify_keys).toHaveProperty(keyid1); + + const keyid2 = 'ed25519:2'; + fetchJsonMock.mockReturnValue( + Promise.resolve({ + server_name: inboundServer, + valid_until_ts: expiresAt.getTime() + 1000, + verify_keys: { + [keyid2]: { key: publicKey }, + }, + old_verify_keys: {}, + signatures: {}, + }), + ); + + await keyService.handleQuery({ + server_keys: { + [inboundServer]: { + [keyid1]: { + minimum_valid_until_ts: Date.now(), + }, + }, + }, + }); + + const finalResponse = await keyService.handleQuery({ + server_keys: { + [inboundServer]: { + [keyid1]: { + minimum_valid_until_ts: expiresAt.getTime(), + }, + }, + }, + }); + + expect(finalResponse.server_keys[0]).toHaveProperty( + 'server_name', + inboundServer, + ); + expect(finalResponse.server_keys[0].verify_keys).toHaveProperty(keyid1); + }); + }), +); diff --git a/packages/federation-sdk/src/services/key.service.ts b/packages/federation-sdk/src/services/key.service.ts new file mode 100644 index 000000000..ed67c52e4 --- /dev/null +++ b/packages/federation-sdk/src/services/key.service.ts @@ -0,0 +1,616 @@ +import { + type KeyV2ServerResponse, + type ServerKey, + fetch as coreFetch, + createLogger, +} from '@rocket.chat/federation-core'; +import { + type Signer, + VerifierKey, + fromBase64ToBytes, + isValidAlgorithm, + loadEd25519VerifierFromPublicKey, + signJson, +} from '@rocket.chat/federation-crypto'; +import { PersistentEventBase } from '@rocket.chat/federation-room'; +import { delay, inject, singleton } from 'tsyringe'; +import { KeyRepository } from '../repositories/key.repository'; +import { getHomeserverFinalAddress } from '../server-discovery/discovery'; +import { ConfigService } from './config.service'; + +type QueryCriteria = { + // If not supplied, the current time as determined by the notary server is used. + minimum_valid_until_ts?: number; +}; + +type QueryRequestBody = { + server_keys: Record< + string /* serverName */, + Record + >; +}; + +function isKeyV2ServerResponse(obj: unknown): obj is KeyV2ServerResponse { + if ( + typeof obj === 'object' && + obj !== null && + 'signatures' in obj && + 'server_name' in obj && + 'verify_keys' in obj && + 'valid_until_ts' in obj && + 'old_verify_keys' in obj + ) { + return true; + } + return false; +} + +// to help with caching and cehcking if a key can still be used +// check isVerifierAllowedToCheckEvent +export type OldVerifierKey = { + key: VerifierKey; + expiredAt: Date; +}; + +@singleton() +export class KeyService { + private logger = createLogger('KeyService'); + constructor( + private readonly configService: ConfigService, + @inject(delay(() => KeyRepository)) + private readonly keyRepository: KeyRepository, + ) {} + + public isVerifierAllowedToCheckEvent( + event: PersistentEventBase, + verifier: OldVerifierKey, + ): boolean { + if (event.originServerTs > verifier.expiredAt.getTime()) { + this.logger.warn( + `Key ${verifier.key.id} expired at ${verifier.expiredAt.toISOString()} cannot be used to verify event from ${event.origin} with originServerTs ${new Date(event.originServerTs).toISOString()}`, + ); + return false; + } + + return true; + } + + private shouldRefetchKey(key: ServerKey, validUntil?: number) { + const { serverName } = key; + + if (validUntil) { + if (key.expiresAt.getTime() < validUntil) { + this.logger.warn( + `Key for ${serverName} is expired, ${key.expiresAt} < ${validUntil}, refetching keys`, + ); + return true; + } + + return false; + } + + // SPEC: Intermediate notary servers should cache a response for half of its lifetime to avoid serving a stale response. + // this could be part of an aggregation, however, the key data stored aren't big enough to justify the complexity right now. + if ((key._updatedAt.getTime() + key.expiresAt.getTime()) / 2 < Date.now()) { + this.logger.warn(`Half life for key for ${serverName} is expired`); + return true; + } + + return false; + } + + private async fetchAndSaveKeysFromRemoteServerRaw( + serverName: string, + ): Promise { + const [address, hostHeaders] = await getHomeserverFinalAddress( + serverName, + this.logger, + ); + + // TODO: move this to federation service?? + const keyV2ServerUrl = new URL(`${address}/_matrix/key/v2/server`); + + const response = await coreFetch(keyV2ServerUrl, { + headers: hostHeaders, + method: 'GET', + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + this.logger.error( + `Failed to fetch keys from remote server ${serverName}: ${response.status} ${response.text()}`, + ); + throw new Error('Failed to fetch keys'); + } + + const data = await response.json(); + + if (!isKeyV2ServerResponse(data)) { + this.logger.error( + { data }, + `Invalid key response from remote server ${serverName}`, + ); + throw new Error('Invalid key response'); + } + + void this.storeKeysFromResponse(data); + + return data; + } + + private parseKeyId(keyId: string) { + // keyId should be in the format : + const [algorithm, version] = keyId.split(':'); + if (!algorithm || !version) { + throw new Error('Invalid keyId format'); + } + + if (!isValidAlgorithm(algorithm)) { + throw new Error('Invalid algorithm in keyId'); + } + + return { algorithm, version }; + } + + private async storeKeysFromResponse( + response: KeyV2ServerResponse, + ): Promise { + const keys: ServerKey[] = []; + + for (const [keyId, keyInfo] of Object.entries(response.verify_keys)) { + const { version } = this.parseKeyId(keyId); + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(keyInfo.key), + version, + ); + + keys.push({ + serverName: response.server_name, + keyId: verifier.id, + key: keyInfo.key, + pem: verifier.getPublicKeyPem(), + + _createdAt: new Date(), + _updatedAt: new Date(), + expiresAt: new Date(response.valid_until_ts), + }); + } + + for (const [keyId, keyInfo] of Object.entries(response.old_verify_keys)) { + const { version } = this.parseKeyId(keyId); + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(keyInfo.key), + version, + ); + + keys.push({ + serverName: response.server_name, + keyId: verifier.id, + key: keyInfo.key, + pem: verifier.getPublicKeyPem(), + + _createdAt: new Date(), + _updatedAt: new Date(), + expiresAt: new Date(keyInfo.expired_ts), + }); + } + + await Promise.all( + keys.map((key) => this.keyRepository.insertOrUpdateKey(key)), + ); + } + + // multiple keys -> single repnse + private async convertToKeyV2Response( + serverKeys: Omit[], + minimumValidUntil = Date.now(), + ): Promise { + const signer = await this.configService.getSigningKey(); + + const verifyKeys: KeyV2ServerResponse['verify_keys'] = {}; + + const oldVerifyKeys: KeyV2ServerResponse['old_verify_keys'] = {}; + + let validUntil = 0; + + const serverName = serverKeys[0]?.serverName; + + for (const key of serverKeys) { + if (key.serverName !== serverName) { + throw new Error('All keys must be from the same server'); + } + + if (key.expiresAt.getTime() > validUntil) { + validUntil = key.expiresAt.getTime(); + } + + // if expired, move to old keys + if (key.expiresAt.getTime() < minimumValidUntil) { + oldVerifyKeys[key.keyId] = { + expired_ts: key.expiresAt.getTime(), + key: key.key, + }; + } else { + verifyKeys[key.keyId] = { + key: key.key, + }; + } + } + + const response = { + server_name: serverName, + verify_keys: verifyKeys, + old_verify_keys: oldVerifyKeys, + valid_until_ts: validUntil, + }; + + const signature = await signJson(response, signer); + + return { + ...response, + signatures: { + [this.configService.serverName]: { + [signer.id]: signature, + }, + }, + }; + } + + // this shouldn't be here, however, to copy the controller level logic from homeserver router to rocket.chat would be a pain to keep up to date if changes are needed. for now, keeping here. + async handleQuery({ server_keys: serverKeys }: QueryRequestBody) { + const serverKeysResponse: KeyV2ServerResponse[] = []; + + const localKeysPerServer: Map = new Map(); + + for (const [serverName, query] of Object.entries(serverKeys)) { + const keysAsked = Object.keys(query); + + this.logger.debug({ serverName, query, keyIds: keysAsked }, 'keys asked'); + + if (keysAsked.length === 0) { + const keys = await this.keyRepository + .findByServerName(serverName) + .toArray(); + + if (!keys.length) { + this.logger.debug({ serverName, keys }, 'no cached keys found'); + // no cache, fetch from remote and stpre + try { + const remoteKeys = + await this.fetchAndSaveKeysFromRemoteServerRaw(serverName); + serverKeysResponse.push(remoteKeys); + + this.logger.debug( + { + response: remoteKeys, + serverName, + }, + 'keys from remote', + ); + } catch (error) { + // SPEC: If the server fails to respond to this request, intermediate notary servers should continue to return the last response they received from the server so that the signatures of old events can still be checked. + this.logger.warn( + { error }, + `Failed to fetch keys from remote server ${serverName}, continuing with cached keys if any`, + ); + + // no cached keys in this instance, we return empty list in that case. + } + continue; + } + + // check if any of the cached keys need refetching + if (keys.every((key) => this.shouldRefetchKey(key))) { + try { + const remoteKeys = + await this.fetchAndSaveKeysFromRemoteServerRaw(serverName); + serverKeysResponse.push(remoteKeys); + + this.logger.debug( + { + response: remoteKeys, + serverName, + }, + 'keys from remote after refetch', + ); + } catch (error) { + // SPEC: If the server fails to respond to this request, intermediate notary servers should continue to return the last response they received from the server so that the signatures of old events can still be checked. + this.logger.warn( + { error }, + `Failed to fetch keys from remote server ${serverName}, continuing with cached keys if any`, + ); + + localKeysPerServer.set(serverName, keys); + } + + continue; + } + + this.logger.debug({ serverName, keys }, 'using cached keys'); + + localKeysPerServer.set(serverName, keys); + + continue; + } + + if ( + !keysAsked.some((keyId) => + isValidAlgorithm(keyId.split(':').shift() ?? ''), + ) + ) { + throw new Error('Invalid keyId format when querying for keys'); + } + + // intentionally querying for all keys from db, since asked, if we didn't find one, should trigger a refetch + const keysForQuery = await this.keyRepository + .findAllByServerNameAndKeyIds(serverName, keysAsked) + .toArray(); + + this.logger.debug( + { serverName, query, keysForQuery }, + 'keys found for query', + ); + + if ( + keysForQuery.length === 0 || + keysForQuery.every((key) => + this.shouldRefetchKey(key, query[key.keyId]?.minimum_valid_until_ts), + ) + ) { + this.logger.debug('need to refetch keys'); + // no valid cache, fetch from remote and stpre + try { + const remoteKeys = + await this.fetchAndSaveKeysFromRemoteServerRaw(serverName); + // TODO: apply actual filter + serverKeysResponse.push(remoteKeys); + + this.logger.debug( + { + response: remoteKeys, + serverName, + }, + 'keys from remote after refetch for query', + ); + } catch (error) { + // SPEC: If the server fails to respond to this request, intermediate notary servers should continue to return the last response they received from the server so that the signatures of old events can still be checked. + this.logger.warn( + { error }, + `Failed to fetch keys from remote server ${serverName}, continuing with cached keys if any`, + ); + + if (keysForQuery.length !== 0) { + localKeysPerServer.set(serverName, keysForQuery); + } + } + + continue; + } + + localKeysPerServer.set(serverName, keysForQuery); + } + + const signer = await this.configService.getSigningKey(); + + const keys = await Promise.all([ + // convert and sign + ...localKeysPerServer + .values() + .map(this.convertToKeyV2Response.bind(this)), + // sign with our keys + ...serverKeysResponse.map(async (key): Promise => { + const { signatures, ...rest } = key; + const signature = await signJson(rest, signer); + + return { + ...rest, + signatures: { + ...signatures, + [this.configService.serverName]: { + [signer.id]: signature, + }, + }, + }; + }), + ]); + + return { + server_keys: keys, + }; + } + + // use only for request signature verification + // does nto include expired keys + async getRequestVerifier( + serverName: string, + keyId: string, + ): Promise { + const { version } = this.parseKeyId(keyId); + + const localKey = await this.keyRepository.findByServerNameAndKeyId( + serverName, + keyId, + new Date(), + ); + + if (localKey && !this.shouldRefetchKey(localKey)) { + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(localKey.key), // TODO: use saved pem here + version, + ); + + return verifier; + } + + // either no key saved or needs a refetch + const remoteKeys = + await this.fetchAndSaveKeysFromRemoteServerRaw(serverName); + if (!remoteKeys.verify_keys[keyId]) { + throw new Error(`Key ${keyId} not found on server ${serverName}`); + } + + if (remoteKeys.valid_until_ts < Date.now()) { + throw new Error(`Key ${keyId} from server ${serverName} is expired`); + } + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(remoteKeys.verify_keys[keyId].key), + version, + ); + + return verifier; + } + + // use for event signature verification + // includes expired keys + async getEventVerifier( + serverName: string, + keyId: string, + expiredAt?: Date, + ): Promise { + const { version } = this.parseKeyId(keyId); + const localKey = await this.keyRepository.findByServerNameAndKeyId( + serverName, + keyId, + expiredAt, + ); + + if (localKey) { + this.logger.debug( + { serverName, keyId, expiredAt }, + 'Found local key for event verification', + ); + // we won't check for half life here, since this is for event signature verification, we need to use whatever is available andd is valid + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(localKey.key), + version, + ); + + return { + key: verifier, + expiredAt: localKey.expiresAt, + }; + } + + const expectedExpiry = expiredAt?.getTime() ?? Date.now(); + + this.logger.debug( + { serverName, keyId }, + `expected expiry: ${new Date(expectedExpiry).toISOString()}`, + ); + + const remoteKeys = + await this.fetchAndSaveKeysFromRemoteServerRaw(serverName); + + this.logger.debug({ response: remoteKeys }, 'Remote keys fetched'); + + if (remoteKeys.verify_keys[keyId]) { + // expired, against the required expiry time + if (remoteKeys.valid_until_ts < expectedExpiry) { + throw new Error(`Key ${keyId} from server ${serverName} is expired`); + } + + const expiredAt_ = new Date(remoteKeys.valid_until_ts); + const publicKey = remoteKeys.verify_keys[keyId].key; + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(publicKey), + version, + ); + + return { + key: verifier, + expiredAt: expiredAt_, + }; + } + + // either are valid for event signing + const publicKey = remoteKeys.old_verify_keys[keyId]; + if (!publicKey) { + throw new Error(`Key ${keyId} not found on server ${serverName}`); + } + + if (publicKey.expired_ts < expectedExpiry) { + throw new Error(`Key ${keyId} from server ${serverName} is expired`); + } + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(publicKey.key), + version, + ); + + return { + key: verifier, + expiredAt: new Date(publicKey.expired_ts), + }; + } + + async getRequiredVerifierForEvent( + event: PersistentEventBase, + ): Promise { + this.logger.debug( + { eventId: event.eventId }, + 'Getting required verifier for event', + ); + + const signaturesFromOrigin = event.event.signatures[event.origin]; + + if (!signaturesFromOrigin) { + throw new Error(`No signatures from origin ${event.origin}`); + } + + // can be signed by multiple keys + for (const keyId of Object.keys(signaturesFromOrigin)) { + try { + const verifier = await this.getEventVerifier( + event.origin, + keyId, + new Date(event.originServerTs), + ); + + this.logger.debug( + { + eventId: event.eventId, + keyId, + origin: event.origin, + }, + 'Found verifier', + ); + + return verifier; + } catch (error) { + // if couldn't find, it's ok, try next + this.logger.warn( + { error, keyId, origin: event.origin }, + `Failed to get verifier for event from ${event.origin} with keyId ${keyId}`, + ); + } + } + + throw new Error( + `No valid signature keys found for origin ${event.origin} with supported algorithms`, + ); + } + + async getOwnSignedServerKeyResponse() { + // TODO: for old keys, should be saved and code below will make more sense + // return this.convertToKeyV2Response( + // await this.keyRepository + // .findByServerName(this.configService.serverName) + // .toArray(), + // ); + const signer = await this.configService.getSigningKey(); + + return this.convertToKeyV2Response([ + { + serverName: this.configService.serverName, + keyId: signer.id, + key: Buffer.from(signer.getPublicKey()).toString('base64'), + + // TODO: this isn't currently in config, nor do we handle expiration yet + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year expiration + }, + ]); + } +} diff --git a/packages/federation-sdk/src/services/room.service.spec.ts b/packages/federation-sdk/src/services/room.service.spec.ts index f610e66f9..674c794e3 100644 --- a/packages/federation-sdk/src/services/room.service.spec.ts +++ b/packages/federation-sdk/src/services/room.service.spec.ts @@ -35,7 +35,7 @@ describe('RoomService', async () => { const configService = new ConfigService(); federationSDK.setConfig({ - signingKey: '', + signingKey: 'FC6cwY3DNmHo3B7GRugaHNyXz+TkBRVx8RvQH0kSZ04', serverName: 'example.com', } as AppConfig); const stateService = container diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index b546b78f4..9406b29a0 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -921,10 +921,9 @@ export class RoomService { makeJoinResponse.room_version, ); - // const signedJoinEvent = await stateService.signEvent(joinEvent); + // TODO stop calling stateService to mutate the event + await stateService.signEvent(joinEvent); - // TODO: sign the event here vvv - // currently makeSignedRequest does the signing const { state, auth_chain: authChain, diff --git a/packages/federation-sdk/src/services/server.service.ts b/packages/federation-sdk/src/services/server.service.ts deleted file mode 100644 index 83f91ba86..000000000 --- a/packages/federation-sdk/src/services/server.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - type SigningKey, - getPublicKeyFromRemoteServer, - signJson, - toUnpaddedBase64, -} from '@rocket.chat/federation-core'; -import { delay, inject, singleton } from 'tsyringe'; -import { ServerRepository } from '../repositories/server.repository'; -import { ConfigService } from './config.service'; - -@singleton() -export class ServerService { - constructor( - @inject(delay(() => ServerRepository)) - private readonly serverRepository: ServerRepository, - private configService: ConfigService, - ) {} - - async getValidPublicKeyFromLocal( - origin: string, - key: string, - ): Promise { - return await this.serverRepository.getValidPublicKeyFromLocal(origin, key); - } - - async storePublicKey( - origin: string, - key: string, - value: string, - validUntil: number, - ): Promise { - await this.serverRepository.storePublicKey(origin, key, value, validUntil); - } - - async getPublicKey(origin: string, key: string): Promise { - if (origin === this.configService.serverName) { - return this.configService.getPublicSigningKeyBase64(); - } - - const localPublicKey = - await this.serverRepository.getValidPublicKeyFromLocal(origin, key); - if (localPublicKey) { - return localPublicKey; - } - - const { key: remotePublicKey, validUntil } = - await getPublicKeyFromRemoteServer( - origin, - this.configService.serverName, - key, - ); - - if (!remotePublicKey) { - throw new Error('Could not get public key from remote server'); - } - - await this.storePublicKey(origin, key, remotePublicKey, validUntil); - return remotePublicKey; - } - - async getSignedServerKey() { - const signingKeys = await this.configService.getSigningKey(); - - const keys = Object.fromEntries( - signingKeys.map((signingKey: SigningKey) => [ - `${signingKey.algorithm}:${signingKey.version}`, - { - key: toUnpaddedBase64(signingKey.publicKey), - }, - ]), - ); - - const baseResponse = { - old_verify_keys: {}, - server_name: this.configService.serverName, - signatures: {}, - valid_until_ts: new Date().getTime() + 60 * 60 * 24 * 1000, // 1 day - verify_keys: keys, - }; - - let signedResponse = baseResponse; - for (const key of signingKeys) { - signedResponse = await signJson( - signedResponse, - key, - this.configService.serverName, - ); - } - - return signedResponse; - } -} diff --git a/packages/federation-sdk/src/services/signature-verification.service.spec.ts b/packages/federation-sdk/src/services/signature-verification.service.spec.ts new file mode 100644 index 000000000..546244b56 --- /dev/null +++ b/packages/federation-sdk/src/services/signature-verification.service.spec.ts @@ -0,0 +1,275 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + test, +} from 'bun:test'; +import { + VerifierKey, + fromBase64ToBytes, + loadEd25519SignerFromSeed, + loadEd25519VerifierFromPublicKey, +} from '@rocket.chat/federation-crypto'; +import { + EventID, + PersistentEventFactory, + RoomID, + UserID, +} from '@rocket.chat/federation-room'; +import { runIfMongoExists } from '../__mocks__/block-if-no-mongo'; +import { keyService } from '../__mocks__/services.spec'; +import { AppConfig, ConfigService } from './config.service'; +import { SignatureVerificationService } from './signature-verification.service'; + +const originServer = 'syn1.tunnel.dev.rocket.chat'; + +const keyId = 'ed25519:a_FAET'; + +// v10 +const event = { + auth_events: [ + '$Hvb-xPPDhTvlXZe2kMubgj8J7iUa5W7YvjTqMTffgUA', + '$Ulggyo4m1OlI08Z0jJDVeceigjSZP9SdEFVoAn9mEh8', + '$G2TzsvetG2YlHr20tZLHCCzOd-yxPa1jeFT8OU4_6kg', + '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs', + ] as EventID[], + content: { + avatar_url: null, + displayname: 'debdut1', + membership: 'join' as const, + }, + depth: 10, + hashes: { sha256: '6MnKSCFJy1fYf6ukILBEbqx2DkoaD1wRyKXhv689a0A' }, + origin: 'syn1.tunnel.dev.rocket.chat', + origin_server_ts: 1757328411218, + prev_events: ['$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs'] as EventID[], + room_id: '!VoUasOLSpcdtRbGHdT:syn2.tunnel.dev.rocket.chat' as RoomID, + sender: '@debdut1:syn1.tunnel.dev.rocket.chat' as UserID, + state_key: '@debdut1:syn1.tunnel.dev.rocket.chat' as UserID, + type: 'm.room.member' as const, + signatures: { + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'eJlvqxPWPe3u+BM4wOwID9YBlh/ZfVVxGYyA5WgpNs5Fe1+c36qrvCKHuXGGjfQoZFrHmZ3/GJw2pv5EvxCZAA', + }, + }, + unsigned: { + age: 1, + replaces_state: '$kXOAfDVvahrwzHEOInzmG941IeEJTn-qUOY0YnLIigs', + prev_content: { displayname: 'debdut1', membership: 'invite' }, + prev_sender: '@debdut:syn2.tunnel.dev.rocket.chat', + }, +}; + +runIfMongoExists(() => + describe('SignatureVerificationService', async () => { + let service: SignatureVerificationService; + + const mockKeyData = { + old_verify_keys: {}, + server_name: 'syn1.tunnel.dev.rocket.chat', + signatures: { + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + '32jfhYKQGENYAByGWZlMPcqgLcGJCoU9RyxOz4TGrmGbTwmbBi8BGbgNJHH8DmWuyoD6FnZ4yI5YBZTJqPjQAA', + }, + }, + valid_until_ts: 1757414678669, + verify_keys: { + 'ed25519:a_FAET': { + key: 'kryovKVnhHESOdWuZ05ViNotRMVdEh/mG2yJ0npLzEo', + }, + }, + }; + + const verifier = await loadEd25519VerifierFromPublicKey( + fromBase64ToBytes(mockKeyData.verify_keys[keyId].key), + 'a_FAET', + ); + + const { MAX_SIGNATURE_LENGTH_FOR_ED25519 } = await import( + './signature-verification.service' + ); + + const configService = new ConfigService(); + configService.setConfig({ + serverName: 'syn2.tunnel.dev.rocket.chat', + } as AppConfig); + + beforeEach(() => { + service = new SignatureVerificationService(keyService, configService); // invalidates internal cache + }); + + afterEach(async () => { + await mock.module('./signature-verification.service', () => ({ + MAX_SIGNATURE_LENGTH_FOR_ED25519, + })); + }); + + describe('verifyRequestSignature', async () => { + const seed = 'FC6cwY3DNmHo3B7GRugaHNyXz+TkBRVx8RvQH0kSZ04'; + const version = 'a_FAET'; + const signer = await loadEd25519SignerFromSeed( + fromBase64ToBytes(seed), + version, + ); + const thisVerifier: VerifierKey = signer; + + it('should successfully validate the request', async () => { + const header = + 'X-Matrix origin="syn1.tunnel.dev.rocket.chat",destination="syn2.tunnel.dev.rocket.chat",key="ed25519:a_FAET",sig="+MRd0eKdc/3T7mS7ZR+ltpOiN7RBXgfxTWWYLejy5gBRXG717aXHPCDm044D10kgqQvs2HqR3MdPEIx+2a0nDg"'; + + const body = { + edus: [ + { + content: { + push: [ + { + last_active_ago: 561472, + presence: 'unavailable', + user_id: '@debdut1:syn1.tunnel.dev.rocket.chat', + }, + ], + }, + edu_type: 'm.presence', + }, + ], + origin: 'syn1.tunnel.dev.rocket.chat', + origin_server_ts: 1757329414731, + pdus: [], + }; + + // PUT /_matrix/federation/v1/send/1757328278684 HTTP/1.1 + + const uri = '/_matrix/federation/v1/send/1757328278684'; + + const method = 'PUT'; + + await expect( + service.verifyRequestSignature( + { + uri, + method, + body, + authorizationHeader: header, + }, + thisVerifier, + ), + ).resolves.toBeString(); + }); + }); + + describe('verifyEventSignature', async () => { + it('01 should verify a valid event signature', async () => { + const pdu = PersistentEventFactory.createFromRawEvent(event, '10'); + + return expect( + service.verifyEventSignature(pdu, verifier), + ).resolves.toBeUndefined(); + }); + + // each step of the spec + it('02 should fail if not signed by the origin server (1)', async () => { + const pdu = PersistentEventFactory.createFromRawEvent( + { + ...event, + signatures: {}, // no signatures + }, + '10', + ); + + return expect( + service.verifyEventSignature(pdu, verifier), + ).rejects.toThrow(`No signature found for origin ${originServer}`); + }); + + it('03 should fail if signed by algorithm not supported by us (ed25519) (2)', async () => { + const pdu = PersistentEventFactory.createFromRawEvent( + { + ...event, + signatures: { + [originServer]: { + // different algorithm + 'not-supported:0': event.signatures[originServer][keyId], + }, + }, + }, + '10', + ); + + return expect( + service.verifyEventSignature(pdu, verifier), + ).rejects.toThrow( + `No valid signature keys found for origin ${originServer} with supported algorithms`, + ); + }); + + test.todo( + 'should pass if service find any of the supported keys from the origin homeserver (3.2)', + async () => { + // need event to be signed by multiple keys + }, + ); + + test.todo( + 'should fail if the signature itself is invalid base64 (4.1)', + async () => { + // need event to be signed by multiple keys + }, + ); + + it('04 should fail if the signature itself is invalid (4.2)', async () => { + const pdu = PersistentEventFactory.createFromRawEvent( + { + ...event, + signatures: { + [originServer]: { + [keyId]: '@@@@', // invalid base64 + }, + }, + }, + '10', + ); + + // should fail because the signature length isn't correct for ed25519 + await expect( + service.verifyEventSignature(pdu, verifier), + ).rejects.toThrow(/Invalid signature length/); + + await mock.module('./signature-verification.service', () => ({ + MAX_SIGNATURE_LENGTH_FOR_ED25519: 4, + })); + + await expect( + service.verifyEventSignature(pdu, verifier), + ).rejects.toThrow(/Failed to decode base64 signature /); + + const anyString = 'abc123'; + const base64String = btoa(anyString); // valid base64 but not a valid signature + + const pdu2 = PersistentEventFactory.createFromRawEvent( + { + ...event, + signatures: { + [originServer]: { + [keyId]: base64String, + }, + }, + }, + '10', + ); + + await mock.module('./signature-verification.service', () => ({ + MAX_SIGNATURE_LENGTH_FOR_ED25519: base64String.length, + })); + + await expect( + service.verifyEventSignature(pdu2, verifier), + ).rejects.toThrow(/Invalid event signature/); + }); + }); + }), +); diff --git a/packages/federation-sdk/src/services/signature-verification.service.ts b/packages/federation-sdk/src/services/signature-verification.service.ts new file mode 100644 index 000000000..5e4f576bd --- /dev/null +++ b/packages/federation-sdk/src/services/signature-verification.service.ts @@ -0,0 +1,276 @@ +import { createLogger } from '@rocket.chat/federation-core'; +import { + InvalidSignatureError, + VerifierKey, + encodeCanonicalJson, + fromBase64ToBytes, + isValidAlgorithm, +} from '@rocket.chat/federation-crypto'; +import type { PersistentEventBase } from '@rocket.chat/federation-room'; +import { singleton } from 'tsyringe'; +import { ConfigService } from './config.service'; +import { KeyService } from './key.service'; + +export class InvalidEventSignatureError extends InvalidSignatureError { + name = 'InvalidEventSignatureError'; +} + +export class InvalidRequestSignatureError extends InvalidSignatureError { + name = 'InvalidRequestSignatureError'; +} + +export class FailedSignatureVerificationPreconditionError extends Error { + name = 'FailedSignatureVerificationPreconditionError'; +} + +// low cost optimization in case of bad implementations +// ed25519 signatures in unpaddedbase64 are always 86 characters long (doing math here for future reference) +// 64 bytes in general, base64 => 21*3 = 63 + 1 padding "==" => 21 * 4 = 84 + 2 padding "==" (each char one byte) => 88 characters +// since in matrix it is unpadded base64, we remove the 2 padding chars => 86 characters +export const MAX_SIGNATURE_LENGTH_FOR_ED25519 = 86; + +export type FederationRequest = + | { + method: 'GET' | 'DELETE'; + uri: `/${string}`; + origin: string; + destination: string; + signature: Record>; + } + | { + method: 'PUT' | 'POST' | 'DELETE'; + uri: `/${string}`; + origin: string; + destination: string; + content: Content; + signature: Record>; + }; + +@singleton() +export class SignatureVerificationService { + constructor( + private readonly keyService: KeyService, + private readonly configService: ConfigService, + ) {} + + private readonly logger = createLogger('SignatureVerificationService'); + + /** + * Implements part of SPEC: https://spec.matrix.org/v1.12/server-server-api/#validating-hashes-and-signatures-on-received-events + * The event structure should be verifier by the time this method is utilized, thus justifying the use of PersistentEventBase. + */ + async verifyEventSignature( + event: PersistentEventBase, + verifier?: VerifierKey, + ): Promise { + // SPEC: First the signature is checked. The event is redacted following the redaction algorithm + const { redactedEvent, origin } = event; + + if (!origin) { + throw new InvalidEventSignatureError( + `Invalid event sender, unable to find origin part from it ${event.sender}`, + ); + } + + const { unsigned: _, ...toCheck } = redactedEvent; + + if (verifier) { + try { + await this.verifySignature(toCheck, origin, verifier); + return; + } catch (error) { + if (error instanceof InvalidSignatureError) { + throw new InvalidEventSignatureError( + `Invalid event signature for event ${event.eventId} from origin ${origin}: ${error.message}`, + ); + } + + throw error; + } + } + + const requiredVerifier = + await this.keyService.getRequiredVerifierForEvent(event); + + if ( + this.keyService.isVerifierAllowedToCheckEvent(event, requiredVerifier) + ) { + try { + await this.verifySignature(toCheck, origin, requiredVerifier.key); + return; + } catch (error) { + if (error instanceof InvalidSignatureError) { + throw new InvalidEventSignatureError( + `Invalid event signature for event ${event.eventId} from origin ${origin}: ${error.message}`, + ); + } + + throw error; + } + } + + throw new FailedSignatureVerificationPreconditionError( + `The verifier with id ${requiredVerifier.key.id} is not allowed to verify the event ${event.eventId} from origin ${origin}`, + ); + } + + async verifyRequestSignature( + { + authorizationHeader, + method, + body, + uri, + }: { + authorizationHeader: string; + method: string; // TODO: type better + body: object | undefined; + uri: string; + }, + verifier?: VerifierKey, + ) { + // `X-Matrix origin="${origin}",destination="${destination}",key="${key}",sig="${signed}"` + + const regex = /\b(origin|destination|key|sig)="([^"]+)"/g; + + const { + origin, + destination, + key, + sig: signature, + ...rest + } = Object.fromEntries( + [...authorizationHeader.matchAll(regex)].map( + ([, key, value]) => [key, value] as const, + ), + ); + + if (Object.keys(rest).length) { + // it should never happen since the regex should match all the parameters + throw new FailedSignatureVerificationPreconditionError( + 'Invalid authorization header, unexpected parameters', + ); + } + + if ([origin, destination, key, signature].some((value) => !value)) { + throw new FailedSignatureVerificationPreconditionError( + 'Invalid authorization header', + ); + } + + if (destination !== this.configService.serverName) { + throw new FailedSignatureVerificationPreconditionError( + 'Header destination does not match this server name', + ); + } + + const toVerify = { + method, + uri, + origin, + destination, + ...(body && { content: body }), + signatures: { + [origin]: { [key]: signature }, + }, + }; + + const signVerifier = + verifier || (await this.keyService.getRequestVerifier(origin, key)); + + try { + await this.verifySignature(toVerify, origin, signVerifier); + + return origin; + } catch (error) { + if (error instanceof InvalidSignatureError) { + throw new InvalidRequestSignatureError( + `Invalid request signature from origin ${origin}: ${error.message}`, + ); + } + + throw error; + } + } + + /** + * Implements SPEC: https://spec.matrix.org/v1.12/appendices/#checking-for-a-signature + */ + async verifySignature< + T extends { + signatures: Record>; + }, + >(data: T, origin: string, verifier: VerifierKey) { + // 1. Checks if the signatures member of the object contains an entry with the name of the entity. If the entry is missing then the check fails. + const originSignature = data.signatures?.[origin]; + if (!originSignature) { + throw new FailedSignatureVerificationPreconditionError( + `No signature found for origin ${origin}`, + ); + } + + // 2. Removes any signing key identifiers from the entry with algorithms it doesn’t understand. If there are no signing key identifiers left then the check fails. + const signatureEntries = Object.entries(originSignature); + const validSignatureEntries = new Map(); + for (const [keyId, signature] of signatureEntries) { + const parts = keyId.split(':'); + if (parts.length < 2) { + this.logger.warn(`Invalid keyId format: ${keyId}`); + continue; // we discard this entry but we do not fail yet + } + + const algorithm = parts[0]; + + if (!isValidAlgorithm(algorithm)) { + this.logger.warn(`Unsupported algorithm: ${algorithm}`); + continue; // we discard this entry but we do not fail yet + } + + validSignatureEntries.set(keyId, signature); + } + if (validSignatureEntries.size === 0) { + throw new FailedSignatureVerificationPreconditionError( + `No valid signature keys found for origin ${origin} with supported algorithms`, + ); + } + + // 3. Looks up verification keys for the remaining signing key identifiers either from a local cache or by consulting a trusted key server. If it cannot find a verification key then the check fails. + // one origin can sign with multiple keys - given how the spec AND the schema structures it. + // we do NOT need all though, one is enough, one that we can fetch first + if (!validSignatureEntries.has(verifier.id)) { + throw new FailedSignatureVerificationPreconditionError( + `No valid verification key found for origin ${origin} with supported algorithms`, + ); + } + + // 4. Decodes the base64 encoded signature bytes. If base64 decoding fails then the check fails. + // we needed to know which key to use to know which signature to decode. + const signatureEntry: string = originSignature[verifier.id]; + if (!signatureEntry) { + throw new FailedSignatureVerificationPreconditionError( + `No signature entry found for keyId ${verifier.id} from origin ${origin}`, + ); + } + + if (signatureEntry.length !== MAX_SIGNATURE_LENGTH_FOR_ED25519) { + throw new FailedSignatureVerificationPreconditionError( + `Invalid signature length for keyId ${verifier.id} from origin ${origin}, expected ${MAX_SIGNATURE_LENGTH_FOR_ED25519} got ${signatureEntry.length} characters`, + ); + } + + const signatureBytes = fromBase64ToBytes(signatureEntry); + if (signatureBytes.byteLength === 0) { + throw new FailedSignatureVerificationPreconditionError( + `Failed to decode base64 signature for keyId ${verifier.id} from origin ${origin}`, + ); + } + + // 5. Removes the signatures and unsigned members of the object. + const { signatures: _, ...rest } = data; + + // 6. Encodes the remainder of the JSON object using the Canonical JSON encoding. + const canonicalJson = encodeCanonicalJson(rest); + + // 7. Checks the signature bytes against the encoded object using the verification key. If this fails then the check fails. Otherwise the check succeeds. + await verifier.verify(canonicalJson, signatureBytes); + } +} diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index 9dd9b4107..0b4017522 100644 --- a/packages/federation-sdk/src/services/state.service.spec.ts +++ b/packages/federation-sdk/src/services/state.service.spec.ts @@ -14,6 +14,8 @@ import { StateMapKey, } from '@rocket.chat/federation-room'; import { type WithId } from 'mongodb'; +import { runIfMongoExists } from '../__mocks__/block-if-no-mongo'; +import { signer } from '../__mocks__/singer.spec'; import { EventRepository } from '../repositories/event.repository'; import { StateGraphRepository, @@ -111,169 +113,62 @@ async function copyDepth< return toStripped; } -describe('StateService', async () => { - if (!process.env.RUN_MONGO_TESTS) { - console.warn('Skipping tests that require a database'); - return; - } - - const databaseConfig = { - uri: - process.env.MONGO_URI || - 'mongodb://localhost:27017?directConnection=true', - name: 'matrix_test', - poolSize: 100, - }; - - const configServiceInstance = { - getSigningKey: async () => {}, - serverName: 'example.com', - } as unknown as ConfigService; - - const database = new DatabaseConnectionService(databaseConfig); - - const eventCollection = (await database.getDb()).collection< - WithId - >('events_test'); - const stateGraphCollection = ( - await database.getDb() - ).collection('state_graph_test'); - - beforeEach(async () => { - await Promise.all([ - eventCollection.deleteMany({}), - stateGraphCollection.deleteMany({}), - ]); - }); - - const eventRepository = new EventRepository(eventCollection); - const stateGraphRepository = new StateGraphRepository(stateGraphCollection); - - // TODO: use IStateService - stateService = new StateService( - stateGraphRepository, - eventRepository, - configServiceInstance, - ); - - const createRoom = async ( - joinRule: PduJoinRuleEventContent['join_rule'], - userPowers: PduPowerLevelsEventContent['users'] = {}, - eventsPowers: PduPowerLevelsEventContent['events'] = {}, - ) => { - const username = '@alice:example.com'; - const name = 'Test Room'; - - const roomCreateEvent = PersistentEventFactory.newCreateEvent( - username as room.UserID, - PersistentEventFactory.defaultRoomVersion, - ); - await stateService.handlePdu(roomCreateEvent); - - const roomVersion: RoomVersion = - roomCreateEvent.getContent().room_version; - - const creatorMembershipEvent = - await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - state_key: username as room.UserID, - content: { membership: 'join' }, - ...getDefaultFields(), - }, - roomVersion, - ); - - await stateService.handlePdu(creatorMembershipEvent); - - const roomNameEvent = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - content: { name }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - }, - roomVersion, - ); - - await stateService.handlePdu(roomNameEvent); +runIfMongoExists(() => + describe('StateService', async () => { + const databaseConfig = { + uri: + process.env.MONGO_URL || + 'mongodb://localhost:27017?directConnection=true', + name: 'matrix_test', + poolSize: 100, + }; - const powerLevelEvent = - await stateService.buildEvent<'m.room.power_levels'>( - { - type: 'm.room.power_levels', - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - state_key: '', - content: { - users: { - [username]: 100, - ...userPowers, - }, - users_default: 0, - events: { - ...eventsPowers, - }, - events_default: 0, - state_default: 50, - ban: 50, - kick: 50, - redact: 50, - invite: 50, - }, - ...getDefaultFields(), - }, - roomVersion, - ); + const configServiceInstance = { + getSigningKey: async () => signer, + serverName: 'example.com', + } as unknown as ConfigService; + + const database = new DatabaseConnectionService(databaseConfig); + + const eventCollection = (await database.getDb()).collection< + WithId + >('events_test'); + const stateGraphCollection = ( + await database.getDb() + ).collection('state_graph_test'); + + beforeEach(async () => { + await Promise.all([ + eventCollection.deleteMany(), + stateGraphCollection.deleteMany(), + ]); + }); - await stateService.handlePdu(powerLevelEvent); + const eventRepository = new EventRepository(eventCollection); + const stateGraphRepository = new StateGraphRepository(stateGraphCollection); - const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( - { - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - content: { join_rule: joinRule }, - type: 'm.room.join_rules', - state_key: '', - ...getDefaultFields(), - }, - roomVersion, + // TODO: use IStateService + stateService = new StateService( + stateGraphRepository, + eventRepository, + configServiceInstance, ); - await stateService.handlePdu(joinRuleEvent); - - return { - roomCreateEvent, - joinRuleEvent, - powerLevelEvent, - creatorMembershipEvent, - roomNameEvent, - }; - }; - - const getStore = ( - cache: Map, - ): room.EventStore => ({ - getEvents: (eventIds: EventID[]) => { - return Promise.resolve(eventIds.map((eid) => cache.get(eid)!)); - }, - }); - - const partialStateEvents = await Promise.all([ - async () => { - const username = '@alice:anotherserver.com' as room.UserID; - const name = 'Test Partial State Room'; + const createRoom = async ( + joinRule: PduJoinRuleEventContent['join_rule'], + userPowers: PduPowerLevelsEventContent['users'] = {}, + ) => { + const username = '@alice:example.com'; + const name = 'Test Room'; const roomCreateEvent = PersistentEventFactory.newCreateEvent( username as room.UserID, PersistentEventFactory.defaultRoomVersion, ); + await stateService.handlePdu(roomCreateEvent); - const roomVersion = roomCreateEvent.version; + const roomVersion: RoomVersion = + roomCreateEvent.getContent().room_version; const creatorMembershipEvent = await stateService.buildEvent<'m.room.member'>( @@ -284,30 +179,11 @@ describe('StateService', async () => { state_key: username as room.UserID, content: { membership: 'join' }, ...getDefaultFields(), - prev_events: [roomCreateEvent.eventId], - auth_events: [roomCreateEvent.eventId], - depth: 1, }, roomVersion, ); - // insert a random message event to make the tree incomplete - const messageEvent = await stateService.buildEvent<'m.room.message'>( - { - room_id: roomCreateEvent.roomId, - sender: username, - content: { body: 'hello world', msgtype: 'm.text' }, - type: 'm.room.message', - ...getDefaultFields(), - prev_events: [creatorMembershipEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 2, - }, - roomVersion, - ); + await stateService.handlePdu(creatorMembershipEvent); const roomNameEvent = await stateService.buildEvent<'m.room.name'>( { @@ -317,33 +193,11 @@ describe('StateService', async () => { state_key: '', type: 'm.room.name', ...getDefaultFields(), - prev_events: [messageEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 3, }, roomVersion, ); - const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( - { - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - content: { join_rule: 'public' }, - type: 'm.room.join_rules', - state_key: '', - ...getDefaultFields(), - prev_events: [roomNameEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - creatorMembershipEvent.eventId, - ], - depth: 4, - }, - roomVersion, - ); + await stateService.handlePdu(roomNameEvent); const powerLevelEvent = await stateService.buildEvent<'m.room.power_levels'>( @@ -355,6 +209,7 @@ describe('StateService', async () => { content: { users: { [username]: 100, + ...userPowers, }, users_default: 0, events: {}, @@ -366,2203 +221,2199 @@ describe('StateService', async () => { invite: 50, }, ...getDefaultFields(), - prev_events: [joinRuleEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - creatorMembershipEvent.eventId, - joinRuleEvent.eventId, - ], - depth: 5, }, roomVersion, ); - const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( + await stateService.handlePdu(powerLevelEvent); + + const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( { - type: 'm.room.member', room_id: roomCreateEvent.roomId, - sender: '@us:example.com' as room.UserID, - state_key: '@us:example.com' as room.UserID, - content: { membership: 'join' }, + sender: username as room.UserID, + content: { join_rule: joinRule }, + type: 'm.room.join_rules', + state_key: '', ...getDefaultFields(), - prev_events: [powerLevelEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - powerLevelEvent.eventId, - joinRuleEvent.eventId, - ], - depth: 6, }, roomVersion, ); - const state = { + + await stateService.handlePdu(joinRuleEvent); + + return { roomCreateEvent, + joinRuleEvent, powerLevelEvent, creatorMembershipEvent, roomNameEvent, - ourUserJoinEvent, - joinRuleEvent, }; + }; - const map = new Map(); - const authChainSet = new Set(); - for (const event of Object.values(state)) { - map.set(event.eventId, event); - } - - const store = getStore(map); - - for (const event of Object.values(state)) { - for (const eventId of await room.getAuthChain(event, store)) { - authChainSet.add(eventId); - } - } - - const authChain = Array.from(authChainSet.values()).map( - (eid) => map.get(eid)!, - ); + const getStore = ( + cache: Map, + ): room.EventStore => ({ + getEvents: (eventIds: EventID[]) => { + return Promise.resolve(eventIds.map((eid) => cache.get(eid)!)); + }, + }); - return { - state, - authChain, - missingEvents: [messageEvent] as PersistentEventBase[], - }; - }, - // multiple missing in the middle - async () => { - const username = '@alice:anotherserver.com' as room.UserID; - const name = 'Test Partial State Room'; + const partialStateEvents = await Promise.all([ + async () => { + const username = '@alice:anotherserver.com' as room.UserID; + const name = 'Test Partial State Room'; - const roomCreateEvent = PersistentEventFactory.newCreateEvent( - username as room.UserID, - PersistentEventFactory.defaultRoomVersion, - ); + const roomCreateEvent = PersistentEventFactory.newCreateEvent( + username as room.UserID, + PersistentEventFactory.defaultRoomVersion, + ); - const roomVersion = roomCreateEvent.version; + const roomVersion = roomCreateEvent.version; + + const creatorMembershipEvent = + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + state_key: username as room.UserID, + content: { membership: 'join' }, + ...getDefaultFields(), + prev_events: [roomCreateEvent.eventId], + auth_events: [roomCreateEvent.eventId], + depth: 1, + }, + roomVersion, + ); - const creatorMembershipEvent = - await stateService.buildEvent<'m.room.member'>( + // insert a random message event to make the tree incomplete + const messageEvent = await stateService.buildEvent<'m.room.message'>( { - type: 'm.room.member', room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - state_key: username as room.UserID, - content: { membership: 'join' }, + sender: username, + content: { body: 'hello world', msgtype: 'm.text' }, + type: 'm.room.message', ...getDefaultFields(), - prev_events: [roomCreateEvent.eventId], - auth_events: [roomCreateEvent.eventId], - depth: 1, + prev_events: [creatorMembershipEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 2, }, roomVersion, ); - // insert a random message event to make the tree incomplete - const messageEvent = await stateService.buildEvent<'m.room.message'>( - { - room_id: roomCreateEvent.roomId, - sender: username, - content: { body: 'hello world', msgtype: 'm.text' }, - type: 'm.room.message', - ...getDefaultFields(), - prev_events: [creatorMembershipEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 2, - }, - roomVersion, - ); - - // this should become an extremity now since no known event will point to this - const roomNameEvent = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - content: { name }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - prev_events: [messageEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 3, - }, - roomVersion, - ); - - // insert another random message event to make the tree incomplete - const messageEvent2 = await stateService.buildEvent<'m.room.message'>( - { - room_id: roomCreateEvent.roomId, - sender: username, - content: { body: 'hello world', msgtype: 'm.text' }, - type: 'm.room.message', - ...getDefaultFields(), - prev_events: [roomNameEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 4, - }, - roomVersion, - ); - - const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( - { - room_id: roomCreateEvent.roomId, - sender: username as room.UserID, - content: { join_rule: 'public' }, - type: 'm.room.join_rules', - state_key: '', - ...getDefaultFields(), - prev_events: [messageEvent2.eventId], - auth_events: [ - roomCreateEvent.eventId, - creatorMembershipEvent.eventId, - ], - depth: 5, - }, - roomVersion, - ); - - const powerLevelEvent = - await stateService.buildEvent<'m.room.power_levels'>( + const roomNameEvent = await stateService.buildEvent<'m.room.name'>( { - type: 'm.room.power_levels', room_id: roomCreateEvent.roomId, sender: username as room.UserID, + content: { name }, state_key: '', - content: { - users: { - [username]: 100, + type: 'm.room.name', + ...getDefaultFields(), + prev_events: [messageEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 3, + }, + roomVersion, + ); + + const joinRuleEvent = + await stateService.buildEvent<'m.room.join_rules'>( + { + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + content: { join_rule: 'public' }, + type: 'm.room.join_rules', + state_key: '', + ...getDefaultFields(), + prev_events: [roomNameEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + ], + depth: 4, + }, + roomVersion, + ); + + const powerLevelEvent = + await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + state_key: '', + content: { + users: { + [username]: 100, + }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, }, - users_default: 0, - events: {}, - events_default: 0, - state_default: 50, - ban: 50, - kick: 50, - redact: 50, - invite: 50, + ...getDefaultFields(), + prev_events: [joinRuleEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + joinRuleEvent.eventId, + ], + depth: 5, }, + roomVersion, + ); + + const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: '@us:example.com' as room.UserID, + state_key: '@us:example.com' as room.UserID, + content: { membership: 'join' }, ...getDefaultFields(), - prev_events: [joinRuleEvent.eventId], + prev_events: [powerLevelEvent.eventId], auth_events: [ roomCreateEvent.eventId, - creatorMembershipEvent.eventId, + powerLevelEvent.eventId, joinRuleEvent.eventId, ], depth: 6, }, roomVersion, ); + const state = { + roomCreateEvent, + powerLevelEvent, + creatorMembershipEvent, + roomNameEvent, + ourUserJoinEvent, + joinRuleEvent, + }; - const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: '@us:example.com' as room.UserID, - state_key: '@us:example.com' as room.UserID, - content: { membership: 'join' }, - ...getDefaultFields(), - prev_events: [powerLevelEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - powerLevelEvent.eventId, - joinRuleEvent.eventId, - ], - depth: 7, - }, - roomVersion, - ); - - const state = { - roomCreateEvent, - powerLevelEvent, - creatorMembershipEvent, - roomNameEvent, - ourUserJoinEvent, - joinRuleEvent, - }; - - const map = new Map(); - const authChainSet = new Set(); - for (const event of Object.values(state)) { - map.set(event.eventId, event); - } - - const store = getStore(map); - - for (const event of Object.values(state)) { - for (const eventId of await room.getAuthChain(event, store)) { - authChainSet.add(eventId); + const map = new Map(); + const authChainSet = new Set(); + for (const event of Object.values(state)) { + map.set(event.eventId, event); } - } - const authChain = Array.from(authChainSet.values()).map( - (eid) => map.get(eid)!, - ); + const store = getStore(map); - return { - state, - authChain, + for (const event of Object.values(state)) { + for (const eventId of await room.getAuthChain(event, store)) { + authChainSet.add(eventId); + } + } - missingEvents: [messageEvent, messageEvent2] as PersistentEventBase[], - }; - }, - async () => { - const creator = '@alice:anotherserver.com' as room.UserID; - const name = 'Test Partial State Room'; + const authChain = Array.from(authChainSet.values()).map( + (eid) => map.get(eid)!, + ); - const roomCreateEvent = PersistentEventFactory.newCreateEvent( - creator as room.UserID, - PersistentEventFactory.defaultRoomVersion, - ); + return { + state, + authChain, + missingEvents: [messageEvent] as PersistentEventBase[], + }; + }, + // multiple missing in the middle + async () => { + const username = '@alice:anotherserver.com' as room.UserID; + const name = 'Test Partial State Room'; + + const roomCreateEvent = PersistentEventFactory.newCreateEvent( + username as room.UserID, + PersistentEventFactory.defaultRoomVersion, + ); - const roomVersion = roomCreateEvent.version; + const roomVersion = roomCreateEvent.version; + + const creatorMembershipEvent = + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + state_key: username as room.UserID, + content: { membership: 'join' }, + ...getDefaultFields(), + prev_events: [roomCreateEvent.eventId], + auth_events: [roomCreateEvent.eventId], + depth: 1, + }, + roomVersion, + ); - const creatorMembershipEvent = - await stateService.buildEvent<'m.room.member'>( + // insert a random message event to make the tree incomplete + const messageEvent = await stateService.buildEvent<'m.room.message'>( { - type: 'm.room.member', room_id: roomCreateEvent.roomId, - sender: creator as room.UserID, - state_key: creator as room.UserID, - content: { membership: 'join' }, + sender: username, + content: { body: 'hello world', msgtype: 'm.text' }, + type: 'm.room.message', ...getDefaultFields(), - prev_events: [roomCreateEvent.eventId], - auth_events: [roomCreateEvent.eventId], - depth: 1, + prev_events: [creatorMembershipEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 2, }, roomVersion, ); - // insert a random message event to make the tree incomplete - const messageEvent = await stateService.buildEvent<'m.room.message'>( - { - room_id: roomCreateEvent.roomId, - sender: creator, - content: { body: 'hello world', msgtype: 'm.text' }, - type: 'm.room.message', - ...getDefaultFields(), - prev_events: [creatorMembershipEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 2, - }, - roomVersion, - ); + // this should become an extremity now since no known event will point to this + const roomNameEvent = await stateService.buildEvent<'m.room.name'>( + { + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + content: { name }, + state_key: '', + type: 'm.room.name', + ...getDefaultFields(), + prev_events: [messageEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 3, + }, + roomVersion, + ); - // this should become an extremity now since no known event will point to this - const roomNameEvent = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomCreateEvent.roomId, - sender: creator as room.UserID, - content: { name }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - prev_events: [messageEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 3, - }, - roomVersion, - ); + // insert another random message event to make the tree incomplete + const messageEvent2 = await stateService.buildEvent<'m.room.message'>( + { + room_id: roomCreateEvent.roomId, + sender: username, + content: { body: 'hello world', msgtype: 'm.text' }, + type: 'm.room.message', + ...getDefaultFields(), + prev_events: [roomNameEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 4, + }, + roomVersion, + ); - // insert another random message event to make the tree incomplete - const messageEvent2 = await stateService.buildEvent<'m.room.message'>( - { - room_id: roomCreateEvent.roomId, - sender: creator, - content: { body: 'hello world', msgtype: 'm.text' }, - type: 'm.room.message', - ...getDefaultFields(), - prev_events: [roomNameEvent.eventId], - auth_events: [ - creatorMembershipEvent.eventId, - roomCreateEvent.eventId, - ], - depth: 4, - }, - roomVersion, - ); + const joinRuleEvent = + await stateService.buildEvent<'m.room.join_rules'>( + { + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + content: { join_rule: 'public' }, + type: 'm.room.join_rules', + state_key: '', + ...getDefaultFields(), + prev_events: [messageEvent2.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + ], + depth: 5, + }, + roomVersion, + ); - const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( - { - room_id: roomCreateEvent.roomId, - sender: creator as room.UserID, - content: { join_rule: 'invite' }, - type: 'm.room.join_rules', - state_key: '', - ...getDefaultFields(), - prev_events: [messageEvent2.eventId], - auth_events: [ - roomCreateEvent.eventId, - creatorMembershipEvent.eventId, - ], - depth: 5, - }, - roomVersion, - ); + const powerLevelEvent = + await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: username as room.UserID, + state_key: '', + content: { + users: { + [username]: 100, + }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + ...getDefaultFields(), + prev_events: [joinRuleEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + joinRuleEvent.eventId, + ], + depth: 6, + }, + roomVersion, + ); - const powerLevelEvent = - await stateService.buildEvent<'m.room.power_levels'>( + const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: '@us:example.com' as room.UserID, + state_key: '@us:example.com' as room.UserID, + content: { membership: 'join' }, + ...getDefaultFields(), + prev_events: [powerLevelEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + powerLevelEvent.eventId, + joinRuleEvent.eventId, + ], + depth: 7, + }, + roomVersion, + ); + + const state = { + roomCreateEvent, + powerLevelEvent, + creatorMembershipEvent, + roomNameEvent, + ourUserJoinEvent, + joinRuleEvent, + }; + + const map = new Map(); + const authChainSet = new Set(); + for (const event of Object.values(state)) { + map.set(event.eventId, event); + } + + const store = getStore(map); + + for (const event of Object.values(state)) { + for (const eventId of await room.getAuthChain(event, store)) { + authChainSet.add(eventId); + } + } + + const authChain = Array.from(authChainSet.values()).map( + (eid) => map.get(eid)!, + ); + + return { + state, + authChain, + + missingEvents: [messageEvent, messageEvent2] as PersistentEventBase[], + }; + }, + async () => { + const creator = '@alice:anotherserver.com' as room.UserID; + const name = 'Test Partial State Room'; + + const roomCreateEvent = PersistentEventFactory.newCreateEvent( + creator as room.UserID, + PersistentEventFactory.defaultRoomVersion, + ); + + const roomVersion = roomCreateEvent.version; + + const creatorMembershipEvent = + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: creator as room.UserID, + state_key: creator as room.UserID, + content: { membership: 'join' }, + ...getDefaultFields(), + prev_events: [roomCreateEvent.eventId], + auth_events: [roomCreateEvent.eventId], + depth: 1, + }, + roomVersion, + ); + + // insert a random message event to make the tree incomplete + const messageEvent = await stateService.buildEvent<'m.room.message'>( + { + room_id: roomCreateEvent.roomId, + sender: creator, + content: { body: 'hello world', msgtype: 'm.text' }, + type: 'm.room.message', + ...getDefaultFields(), + prev_events: [creatorMembershipEvent.eventId], + auth_events: [ + creatorMembershipEvent.eventId, + roomCreateEvent.eventId, + ], + depth: 2, + }, + roomVersion, + ); + + // this should become an extremity now since no known event will point to this + const roomNameEvent = await stateService.buildEvent<'m.room.name'>( { - type: 'm.room.power_levels', room_id: roomCreateEvent.roomId, sender: creator as room.UserID, + content: { name }, state_key: '', - content: { - users: { - [creator]: 100, - }, - users_default: 0, - events: {}, - events_default: 0, - state_default: 50, - ban: 50, - kick: 50, - redact: 50, - invite: 50, - }, + type: 'm.room.name', ...getDefaultFields(), - prev_events: [joinRuleEvent.eventId], + prev_events: [messageEvent.eventId], auth_events: [ + creatorMembershipEvent.eventId, roomCreateEvent.eventId, + ], + depth: 3, + }, + roomVersion, + ); + + // insert another random message event to make the tree incomplete + const messageEvent2 = await stateService.buildEvent<'m.room.message'>( + { + room_id: roomCreateEvent.roomId, + sender: creator, + content: { body: 'hello world', msgtype: 'm.text' }, + type: 'm.room.message', + ...getDefaultFields(), + prev_events: [roomNameEvent.eventId], + auth_events: [ creatorMembershipEvent.eventId, - joinRuleEvent.eventId, + roomCreateEvent.eventId, ], - depth: 6, + depth: 4, }, roomVersion, ); - const ourUserInviteEvent = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: creator, - state_key: '@us:example.com' as room.UserID, - content: { membership: 'invite' }, - ...getDefaultFields(), - prev_events: [powerLevelEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - powerLevelEvent.eventId, - joinRuleEvent.eventId, - creatorMembershipEvent.eventId, - ], - depth: 7, - }, - roomVersion, - ); + const joinRuleEvent = + await stateService.buildEvent<'m.room.join_rules'>( + { + room_id: roomCreateEvent.roomId, + sender: creator as room.UserID, + content: { join_rule: 'invite' }, + type: 'm.room.join_rules', + state_key: '', + ...getDefaultFields(), + prev_events: [messageEvent2.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + ], + depth: 5, + }, + roomVersion, + ); - const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: '@us:example.com' as room.UserID, - state_key: '@us:example.com' as room.UserID, - content: { membership: 'join' }, - ...getDefaultFields(), - prev_events: [ourUserInviteEvent.eventId], - auth_events: [ - roomCreateEvent.eventId, - powerLevelEvent.eventId, - joinRuleEvent.eventId, - ourUserInviteEvent.eventId, - ], - depth: 8, - }, - roomVersion, - ); + const powerLevelEvent = + await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: creator as room.UserID, + state_key: '', + content: { + users: { + [creator]: 100, + }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + ...getDefaultFields(), + prev_events: [joinRuleEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + creatorMembershipEvent.eventId, + joinRuleEvent.eventId, + ], + depth: 6, + }, + roomVersion, + ); - const state = { - roomCreateEvent, - powerLevelEvent, - creatorMembershipEvent, - roomNameEvent, - ourUserJoinEvent, - ourUserInviteEvent, - joinRuleEvent, - }; + const ourUserInviteEvent = + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: creator, + state_key: '@us:example.com' as room.UserID, + content: { membership: 'invite' }, + ...getDefaultFields(), + prev_events: [powerLevelEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + powerLevelEvent.eventId, + joinRuleEvent.eventId, + creatorMembershipEvent.eventId, + ], + depth: 7, + }, + roomVersion, + ); - const map = new Map(); - const authChainSet = new Set(); - for (const event of Object.values(state)) { - map.set(event.eventId, event); - } + const ourUserJoinEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: '@us:example.com' as room.UserID, + state_key: '@us:example.com' as room.UserID, + content: { membership: 'join' }, + ...getDefaultFields(), + prev_events: [ourUserInviteEvent.eventId], + auth_events: [ + roomCreateEvent.eventId, + powerLevelEvent.eventId, + joinRuleEvent.eventId, + ourUserInviteEvent.eventId, + ], + depth: 8, + }, + roomVersion, + ); - const store = getStore(map); + const state = { + roomCreateEvent, + powerLevelEvent, + creatorMembershipEvent, + roomNameEvent, + ourUserJoinEvent, + ourUserInviteEvent, + joinRuleEvent, + }; - for (const event of Object.values(state)) { - for (const eventId of await room.getAuthChain(event, store)) { - authChainSet.add(eventId); + const map = new Map(); + const authChainSet = new Set(); + for (const event of Object.values(state)) { + map.set(event.eventId, event); } - } - const authChain = Array.from(authChainSet.values()).map( - (eid) => map.get(eid)!, - ); + const store = getStore(map); - return { - state, - authChain, + for (const event of Object.values(state)) { + for (const eventId of await room.getAuthChain(event, store)) { + authChainSet.add(eventId); + } + } - missingEvents: [messageEvent, messageEvent2] as PersistentEventBase[], - }; - }, - ]); + const authChain = Array.from(authChainSet.values()).map( + (eid) => map.get(eid)!, + ); - const joinUser = async (roomId: string, userId: string) => { - return _setUserMembership(roomId, userId, 'join'); - }; + return { + state, + authChain, - const banUser = async (roomId: string, userId: string, sender: string) => { - return _setUserMembership(roomId, userId, 'ban', sender); - }; + missingEvents: [messageEvent, messageEvent2] as PersistentEventBase[], + }; + }, + ]); - const leaveUser = async (roomId: string, userId: string) => { - return _setUserMembership(roomId, userId, 'leave'); - }; + const joinUser = async (roomId: string, userId: string) => { + return _setUserMembership(roomId, userId, 'join'); + }; - const inviteUser = async (roomId: string, userId: string, sender: string) => { - return _setUserMembership(roomId, userId, 'invite', sender); - }; + const banUser = async (roomId: string, userId: string, sender: string) => { + return _setUserMembership(roomId, userId, 'ban', sender); + }; - const _setUserMembership = async ( - roomId: string, - userId: string, - membership: room.PduMembershipEventContent['membership'], - sender?: string, - ) => { - const roomVersion = await stateService.getRoomVersion(roomId); - const membershipEvent = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomId as room.RoomID, - sender: (sender || userId) as room.UserID, - state_key: userId as room.UserID, - content: { membership: membership }, - ...getDefaultFields(), - }, - roomVersion, - ); + const leaveUser = async (roomId: string, userId: string) => { + return _setUserMembership(roomId, userId, 'leave'); + }; - await stateService.handlePdu(membershipEvent); + const inviteUser = async ( + roomId: string, + userId: string, + sender: string, + ) => { + return _setUserMembership(roomId, userId, 'invite', sender); + }; - return membershipEvent; - }; + const _setUserMembership = async ( + roomId: string, + userId: string, + membership: room.PduMembershipEventContent['membership'], + sender?: string, + ) => { + const roomVersion = await stateService.getRoomVersion(roomId); + const membershipEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomId as room.RoomID, + sender: (sender || userId) as room.UserID, + state_key: userId as room.UserID, + content: { membership: membership }, + ...getDefaultFields(), + }, + roomVersion, + ); - it('001 should correctly calculate state through linear changes', async () => { - const { - roomCreateEvent, - roomNameEvent, - joinRuleEvent, - powerLevelEvent, - creatorMembershipEvent, - } = await createRoom('public'); - - const stateAtEvent = new Map(); - - const roomId = roomCreateEvent.roomId; - const creator = roomCreateEvent.getContent().creator as room.UserID; - - const state = await stateService.getLatestRoomState(roomId); - - // check each event - expect( - state.get(roomCreateEvent.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', roomCreateEvent.eventId); - expect(state.get(roomNameEvent.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - roomNameEvent.eventId, - ); - expect(state.get(joinRuleEvent.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - joinRuleEvent.eventId, - ); - expect( - state.get(powerLevelEvent.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', powerLevelEvent.eventId); - expect( - state.get(creatorMembershipEvent.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', creatorMembershipEvent.eventId); - - expect(state.size).toBe(5); - - const bob = '@bob:example.com'; - const bobJoinEvent = await joinUser(roomId, bob); - - const state2 = await stateService.getLatestRoomState(roomId); - expect(state2.size).toBe(6); - expect(state2.get(bobJoinEvent.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - bobJoinEvent.eventId, - ); + await stateService.handlePdu(membershipEvent); - stateAtEvent.set(bobJoinEvent.eventId, state2); + return membershipEvent; + }; - const bobLeaveEvent = await leaveUser(roomId, bob); - const state3 = await stateService.getLatestRoomState(roomId); + it('001 should correctly calculate state through linear changes', async () => { + const { + roomCreateEvent, + roomNameEvent, + joinRuleEvent, + powerLevelEvent, + creatorMembershipEvent, + } = await createRoom('public'); - expect(state3.size).toBe(6); // same as before - expect(state3.get(bobLeaveEvent.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - bobLeaveEvent.eventId, - ); + const stateAtEvent = new Map(); - stateAtEvent.set(bobLeaveEvent.eventId, state3); - - // do same for random1 and random2 - const random1 = '@random1:example.com'; - const random1JoinEvent = await joinUser(roomId, random1); - const state4 = await stateService.getLatestRoomState(roomId); - expect(state4.size).toBe(7); - expect( - state4.get(random1JoinEvent.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', random1JoinEvent.eventId); - const random2 = '@random2:example.com'; - const random2JoinEvent = await joinUser(roomId, random2); - const state5 = await stateService.getLatestRoomState(roomId); - expect(state5.size).toBe(8); - expect( - state5.get(random2JoinEvent.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', random2JoinEvent.eventId); - - stateAtEvent.set(random1JoinEvent.eventId, state4); - stateAtEvent.set(random2JoinEvent.eventId, state5); - - // change room name now - const newRoomName = 'New Room Name'; - const roomNameEvent2 = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomId, - sender: roomCreateEvent.getContent() - .creator as room.UserID, - content: { name: newRoomName }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); - await stateService.handlePdu(roomNameEvent2); - const state6 = await stateService.getLatestRoomState(roomId); - expect(state6.size).toBe(8); // same as before, overwriting existing name - expect( - state6.get(roomNameEvent2.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', roomNameEvent2.eventId); - - stateAtEvent.set(roomNameEvent2.eventId, state6); - - // ban random3 - const random3 = '@random3:example.com'; - const banRandom1Event = await banUser(roomId, random3, creator); - const state7 = await stateService.getLatestRoomState(roomId); - expect(state7.size).toBe(9); - expect( - state7.get(banRandom1Event.getUniqueStateIdentifier()), - ).toHaveProperty('eventId', banRandom1Event.eventId); - - stateAtEvent.set(banRandom1Event.eventId, state7); - - // have random3 change the name of the room - const roomNameEvent3 = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomId, - sender: random3 as room.UserID, - content: { name: 'Hacked Name' }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); - expect(stateService.handlePdu(roomNameEvent3)).rejects.toThrow(); - const state8 = await stateService.getLatestRoomState(roomId); - expect(state8.size).toBe(9); // same as before, bob was banned can't change name - compareStates(state7, state8); + const roomId = roomCreateEvent.roomId; + const creator = roomCreateEvent.getContent().creator as room.UserID; - const stateShouldBe6 = await stateService.getStateAtEvent(roomNameEvent2); - compareStates(stateAtEvent.get(roomNameEvent2.eventId)!, stateShouldBe6); + const state = await stateService.getLatestRoomState(roomId); - // send a message - const message = await stateService.buildEvent<'m.room.message'>( - { - type: 'm.room.message', - room_id: roomId, - sender: creator, - content: { msgtype: 'm.text', body: '' }, - ...getDefaultFields(), - }, - roomCreateEvent.version, - ); + // check each event + expect( + state.get(roomCreateEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', roomCreateEvent.eventId); + expect( + state.get(roomNameEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', roomNameEvent.eventId); + expect( + state.get(joinRuleEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', joinRuleEvent.eventId); + expect( + state.get(powerLevelEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', powerLevelEvent.eventId); + expect( + state.get(creatorMembershipEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', creatorMembershipEvent.eventId); - await stateService.handlePdu(message); + expect(state.size).toBe(5); - const state9 = await stateService.getLatestRoomState(roomId); - compareStates(state9, state8); // shouldn't change state + const bob = '@bob:example.com'; + const bobJoinEvent = await joinUser(roomId, bob); - const stateAtMessage = await stateService.getStateAtEvent(message); - compareStates(stateAtMessage, state9); - }); + const state2 = await stateService.getLatestRoomState(roomId); + expect(state2.size).toBe(6); + expect( + state2.get(bobJoinEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', bobJoinEvent.eventId); - it('01 should return the correct room information for room id', async () => { - expect(stateService.getRoomInformation('abcd')).rejects.toThrowError( - /Create event mapping not found/, - ); + stateAtEvent.set(bobJoinEvent.eventId, state2); - const { roomCreateEvent } = await createRoom('public'); + const bobLeaveEvent = await leaveUser(roomId, bob); + const state3 = await stateService.getLatestRoomState(roomId); - expect( - stateService.getRoomInformation(roomCreateEvent.roomId), - ).resolves.toHaveProperty( - 'creator', - roomCreateEvent.getContent().creator, - ); - }); + expect(state3.size).toBe(6); // same as before + expect( + state3.get(bobLeaveEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', bobLeaveEvent.eventId); - it('02 should get the correct room version', async () => { - const { roomCreateEvent } = await createRoom('public'); + stateAtEvent.set(bobLeaveEvent.eventId, state3); - const roomVersion = await stateService.getRoomVersion( - roomCreateEvent.roomId, - ); + // do same for random1 and random2 + const random1 = '@random1:example.com'; + const random1JoinEvent = await joinUser(roomId, random1); + const state4 = await stateService.getLatestRoomState(roomId); + expect(state4.size).toBe(7); + expect( + state4.get(random1JoinEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', random1JoinEvent.eventId); + const random2 = '@random2:example.com'; + const random2JoinEvent = await joinUser(roomId, random2); + const state5 = await stateService.getLatestRoomState(roomId); + expect(state5.size).toBe(8); + expect( + state5.get(random2JoinEvent.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', random2JoinEvent.eventId); - expect(roomVersion).toBe( - roomCreateEvent.getContent() - .room_version as RoomVersion, - ); + stateAtEvent.set(random1JoinEvent.eventId, state4); + stateAtEvent.set(random2JoinEvent.eventId, state5); - expect(stateService.getRoomVersion('roomId')).rejects.toThrowError(); - }); + // change room name now + const newRoomName = 'New Room Name'; + const roomNameEvent2 = await stateService.buildEvent<'m.room.name'>( + { + room_id: roomId, + sender: roomCreateEvent.getContent() + .creator as room.UserID, + content: { name: newRoomName }, + state_key: '', + type: 'm.room.name', + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ); + await stateService.handlePdu(roomNameEvent2); + const state6 = await stateService.getLatestRoomState(roomId); + expect(state6.size).toBe(8); // same as before, overwriting existing name + expect( + state6.get(roomNameEvent2.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', roomNameEvent2.eventId); - it('03 should find the correct state at an event', async () => { - const { roomCreateEvent } = await createRoom('public'); + stateAtEvent.set(roomNameEvent2.eventId, state6); - const events = await Promise.all([ - joinUser(roomCreateEvent.roomId, '@bob:example.com'), - joinUser(roomCreateEvent.roomId, '@charlie:example.com'), - // random1 - joinUser(roomCreateEvent.roomId, '@random1:example.com'), - joinUser(roomCreateEvent.roomId, '@random2:example.com'), - ]); + // ban random3 + const random3 = '@random3:example.com'; + const banRandom1Event = await banUser(roomId, random3, creator); + const state7 = await stateService.getLatestRoomState(roomId); + expect(state7.size).toBe(9); + expect( + state7.get(banRandom1Event.getUniqueStateIdentifier()), + ).toHaveProperty('eventId', banRandom1Event.eventId); + + stateAtEvent.set(banRandom1Event.eventId, state7); + + // have random3 change the name of the room + const roomNameEvent3 = await stateService.buildEvent<'m.room.name'>( + { + room_id: roomId, + sender: random3 as room.UserID, + content: { name: 'Hacked Name' }, + state_key: '', + type: 'm.room.name', + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ); + expect(stateService.handlePdu(roomNameEvent3)).rejects.toThrow(); + const state8 = await stateService.getLatestRoomState(roomId); + expect(state8.size).toBe(9); // same as before, bob was banned can't change name + compareStates(state7, state8); - // at each event the corresponding user should be in state + const stateShouldBe6 = await stateService.getStateAtEvent(roomNameEvent2); + compareStates(stateAtEvent.get(roomNameEvent2.eventId)!, stateShouldBe6); - for (const event of events) { - const stateAtEvent = await stateService.getStateAtEvent(event); - expect( - stateAtEvent.get(event.getUniqueStateIdentifier())?.getContent(), - ).toHaveProperty('membership', 'join'); - } - }); + // send a message + const message = await stateService.buildEvent<'m.room.message'>( + { + type: 'm.room.message', + room_id: roomId, + sender: creator, + content: { msgtype: 'm.text', body: '' }, + ...getDefaultFields(), + }, + roomCreateEvent.version, + ); - // NOTE: need state_id implementation and correlate with synapse to confirm the behavior - // challenge is if we get a duplicate and drop it from state, but is still part of prev_events as new events come in, how are we supposed to behave then? - // at the same time if we send out a duplicate, it will be dropped by the other side, but we will continue to - // add it in prev_events. - // idempotency both ways is important, at the same time need to be able to handle non idempotent requests - test.failing('should make idempotent state changes', async () => { - const { roomCreateEvent } = await createRoom('public'); + await stateService.handlePdu(message); - const newUser = '@bob:example.com'; + const state9 = await stateService.getLatestRoomState(roomId); + compareStates(state9, state8); // shouldn't change state - const joinEvent1 = await joinUser(roomCreateEvent.roomId, newUser); + const stateAtMessage = await stateService.getStateAtEvent(message); + compareStates(stateAtMessage, state9); + }); - const state1 = await stateService.getLatestRoomState( - roomCreateEvent.roomId, - ); - expect(state1.get(joinEvent1.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - joinEvent1.eventId, - ); + it('01 should return the correct room information for room id', async () => { + expect(stateService.getRoomInformation('abcd')).rejects.toThrowError( + /Create event mapping not found/, + ); - const joinEvent2 = await joinUser(roomCreateEvent.roomId, newUser); + const { roomCreateEvent } = await createRoom('public'); - expect(joinEvent1.eventId).not.toBe(joinEvent2.eventId); + expect( + stateService.getRoomInformation(roomCreateEvent.roomId), + ).resolves.toHaveProperty( + 'creator', + roomCreateEvent.getContent().creator, + ); + }); - const state2 = await stateService.getLatestRoomState( - roomCreateEvent.roomId, - ); - expect(state2.get(joinEvent2.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - joinEvent1.eventId, // same as old eventid - ); - }); + it('02 should get the correct room version', async () => { + const { roomCreateEvent } = await createRoom('public'); - it('05 should create a room successfully', async () => { - const { - roomCreateEvent: { roomId }, - } = await createRoom('public'); - expect(roomId).toBeDefined(); - return expect( - stateService.getLatestRoomState2(roomId), - ).resolves.toBeDefined(); - }); + const roomVersion = await stateService.getRoomVersion( + roomCreateEvent.roomId, + ); - it('06 should successfully have a user join the room', async () => { - const { roomCreateEvent } = await createRoom('public'); + expect(roomVersion).toBe( + roomCreateEvent.getContent() + .room_version as RoomVersion, + ); - const newUser = '@bob:example.com'; + expect(stateService.getRoomVersion('roomId')).rejects.toThrowError(); + }); - await joinUser(roomCreateEvent.roomId, newUser); + it('03 should find the correct state at an event', async () => { + const { roomCreateEvent } = await createRoom('public'); - const state = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state.isUserInRoom(newUser)).toBe(true); - }); + const events = await Promise.all([ + joinUser(roomCreateEvent.roomId, '@bob:example.com'), + joinUser(roomCreateEvent.roomId, '@charlie:example.com'), + // random1 + joinUser(roomCreateEvent.roomId, '@random1:example.com'), + joinUser(roomCreateEvent.roomId, '@random2:example.com'), + ]); - it('07 should have a user leave the room successfully', async () => { - const { roomCreateEvent } = await createRoom('public'); - const newUser = '@bob:example.com'; + // at each event the corresponding user should be in state - await joinUser(roomCreateEvent.roomId, newUser); + for (const event of events) { + const stateAtEvent = await stateService.getStateAtEvent(event); + expect( + stateAtEvent.get(event.getUniqueStateIdentifier())?.getContent(), + ).toHaveProperty('membership', 'join'); + } + }); - await leaveUser(roomCreateEvent.roomId, newUser); + // NOTE: need state_id implementation and correlate with synapse to confirm the behavior + // challenge is if we get a duplicate and drop it from state, but is still part of prev_events as new events come in, how are we supposed to behave then? + // at the same time if we send out a duplicate, it will be dropped by the other side, but we will continue to + // add it in prev_events. + // idempotency both ways is important, at the same time need to be able to handle non idempotent requests + test.failing('should make idempotent state changes', async () => { + const { roomCreateEvent } = await createRoom('public'); - const state = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state.getUserMembership(newUser)).toBe('leave'); - }); + const newUser = '@bob:example.com'; - it('08 should not allow joining if room is imenvite only', async () => { - const { roomCreateEvent } = await createRoom('invite'); - const newUser = '@bob:example.com' as room.UserID; - const membershipEvent = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: newUser, - state_key: newUser, - content: { membership: 'join' }, - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); + const joinEvent1 = await joinUser(roomCreateEvent.roomId, newUser); - expect(stateService.handlePdu(membershipEvent)).rejects.toThrow(); + const state1 = await stateService.getLatestRoomState( + roomCreateEvent.roomId, + ); + expect(state1.get(joinEvent1.getUniqueStateIdentifier())).toHaveProperty( + 'eventId', + joinEvent1.eventId, + ); - expect(membershipEvent.rejected).toBeTrue(); - expect(membershipEvent.rejectCode).toBe(RejectCodes.AuthError); - }); + const joinEvent2 = await joinUser(roomCreateEvent.roomId, newUser); - it('09 should allow joining if invited in invite only room', async () => { - const { roomCreateEvent } = await createRoom('invite'); - const newUser = '@bob:example.com' as room.UserID; + expect(joinEvent1.eventId).not.toBe(joinEvent2.eventId); - await inviteUser( - roomCreateEvent.roomId, - newUser, - roomCreateEvent.getContent().creator, - ); + const state2 = await stateService.getLatestRoomState( + roomCreateEvent.roomId, + ); + expect(state2.get(joinEvent2.getUniqueStateIdentifier())).toHaveProperty( + 'eventId', + joinEvent1.eventId, // same as old eventid + ); + }); - expect( - ( - await stateService.getLatestRoomState2(roomCreateEvent.roomId) - ).isUserInvited(newUser), - ).toBeTrue(); + it('05 should create a room successfully', async () => { + const { + roomCreateEvent: { roomId }, + } = await createRoom('public'); + expect(roomId).toBeDefined(); + return expect( + stateService.getLatestRoomState2(roomId), + ).resolves.toBeDefined(); + }); - await joinUser(roomCreateEvent.roomId, newUser); + it('06 should successfully have a user join the room', async () => { + const { roomCreateEvent } = await createRoom('public'); - const state = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state.isUserInRoom(newUser)).toBe(true); - }); + const newUser = '@bob:example.com'; - it('10 should not allow joining if banned', async () => { - const { roomCreateEvent } = await createRoom('public'); - const newUser = '@bob:example.com' as room.UserID; - // join first - await joinUser(roomCreateEvent.roomId, newUser); - - expect( - ( - await stateService.getLatestRoomState2(roomCreateEvent.roomId) - ).isUserInRoom(newUser), - ).toBeTrue(); - - await banUser( - roomCreateEvent.roomId, - newUser, - roomCreateEvent.getContent().creator, - ); + await joinUser(roomCreateEvent.roomId, newUser); - expect( - ( - await stateService.getLatestRoomState2(roomCreateEvent.roomId) - ).getUserMembership(newUser), - ).toBe('ban'); + const state = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state.isUserInRoom(newUser)).toBe(true); + }); - const membershipEventJoin2 = await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: newUser, - state_key: newUser, - content: { membership: 'join' }, - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); + it('07 should have a user leave the room successfully', async () => { + const { roomCreateEvent } = await createRoom('public'); + const newUser = '@bob:example.com'; - expect(stateService.handlePdu(membershipEventJoin2)).rejects.toThrow(); - expect(membershipEventJoin2.rejected).toBeTrue(); - expect(membershipEventJoin2.rejectCode).toBe(RejectCodes.AuthError); - }); + await joinUser(roomCreateEvent.roomId, newUser); - it('11 should soft fail events', async () => { - const { roomCreateEvent } = await createRoom('public'); - - // add a user - const bob = '@bob:example.com' as room.UserID; - await joinUser(roomCreateEvent.roomId, bob); - // ban bob now - const banBobEvent = await banUser( - roomCreateEvent.roomId, - bob, - roomCreateEvent.getContent().creator, - ); + await leaveUser(roomCreateEvent.roomId, newUser); - const state1 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state1.getUserMembership(bob)).toBe('ban'); + const state = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state.getUserMembership(newUser)).toBe('leave'); + }); - // now we try to make bob "leave", but set the depth manually to be before he was banned - // leave is a state event - const bobLeaveEvent = stripPreviousAndAuthEvents( - await stateService.buildEvent<'m.room.member'>( + it('08 should not allow joining if room is imenvite only', async () => { + const { roomCreateEvent } = await createRoom('invite'); + const newUser = '@bob:example.com' as room.UserID; + const membershipEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', room_id: roomCreateEvent.roomId, - sender: bob, - state_key: bob, - content: { membership: 'leave' }, + sender: newUser, + state_key: newUser, + content: { membership: 'join' }, ...getDefaultFields(), }, roomCreateEvent.getContent().room_version, - ), - ); + ); - const store = stateService._getStore( - roomCreateEvent.getContent() - .room_version as RoomVersion, - ); + expect(stateService.handlePdu(membershipEvent)).rejects.toThrow(); - const eventsBeforeBobWasBanned = await store.getEvents( - banBobEvent.getPreviousEventIds(), - ); + expect(membershipEvent.rejected).toBeTrue(); + expect(membershipEvent.rejectCode).toBe(RejectCodes.AuthError); + }); - const authEventsForBobBan = await store.getEvents( - banBobEvent.getAuthEventIds(), - ); // should be the same for bob + it('09 should allow joining if invited in invite only room', async () => { + const { roomCreateEvent } = await createRoom('invite'); + const newUser = '@bob:example.com' as room.UserID; - bobLeaveEvent.addPrevEvents(eventsBeforeBobWasBanned); + await inviteUser( + roomCreateEvent.roomId, + newUser, + roomCreateEvent.getContent().creator, + ); - // biome-ignore lint/complexity/noForEach: - authEventsForBobBan.forEach((e) => bobLeaveEvent.authedBy(e)); + expect( + ( + await stateService.getLatestRoomState2(roomCreateEvent.roomId) + ).isUserInvited(newUser), + ).toBeTrue(); - expect(stateService.handlePdu(bobLeaveEvent)).rejects.toThrow(); - expect(bobLeaveEvent.rejected).toBeTrue(); - expect(bobLeaveEvent.rejectCode).toBe(RejectCodes.AuthError); - }); - it('should reject event if the power level is not high enough', async () => { - const { roomCreateEvent } = await createRoom( - 'public', - {}, - { 'rc.message': 70 }, - ); - const joinEvent = await joinUser( - roomCreateEvent.roomId, - '@bob:example.com', - ); + await joinUser(roomCreateEvent.roomId, newUser); - const messageEvent = await stateService.buildEvent( - { - // @ts-expect-error - testing unknown event type - type: 'rc.message', - room_id: roomCreateEvent.roomId, - sender: joinEvent.sender, - content: { body: 'hello world', msgtype: 'm.text' }, - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); + const state = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state.isUserInRoom(newUser)).toBe(true); + }); - await expect(() => stateService.handlePdu(messageEvent)).toThrow( - RejectCodes.AuthError, - ); - }); + it('10 should not allow joining if banned', async () => { + const { roomCreateEvent } = await createRoom('public'); + const newUser = '@bob:example.com' as room.UserID; + // join first + await joinUser(roomCreateEvent.roomId, newUser); - it('should allow event if the power level is high enough', async () => { - const { roomCreateEvent, creatorMembershipEvent } = await createRoom( - 'public', - {}, - { 'rc.message': 70 }, - ); + expect( + ( + await stateService.getLatestRoomState2(roomCreateEvent.roomId) + ).isUserInRoom(newUser), + ).toBeTrue(); + + await banUser( + roomCreateEvent.roomId, + newUser, + roomCreateEvent.getContent().creator, + ); - const messageEvent = await stateService.buildEvent( - { - // @ts-expect-error - testing unknown event type - type: 'rc.message', - room_id: roomCreateEvent.roomId, - sender: creatorMembershipEvent.sender, - content: { body: 'hello world', msgtype: 'm.text' }, - ...getDefaultFields(), - }, - roomCreateEvent.getContent().room_version, - ); + expect( + ( + await stateService.getLatestRoomState2(roomCreateEvent.roomId) + ).getUserMembership(newUser), + ).toBe('ban'); - await stateService.handlePdu(messageEvent); - expect(messageEvent.rejected).toBeFalsy(); - }); + const membershipEventJoin2 = + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: newUser, + state_key: newUser, + content: { membership: 'join' }, + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ); - it('12 should save unknown and custom events to database without throwing errors', async () => { - const { roomCreateEvent } = await createRoom('public'); - const roomId = roomCreateEvent.roomId; - const roomVersion = - roomCreateEvent.getContent().room_version; - - // add a user with power to send events (events_default is 0 by default) - const bob = '@bob:example.com' as room.UserID; - await joinUser(roomId, bob); - - // test 1: custom application event as timeline event (io.rocketchat.*) - const customEvent = await stateService.buildEvent( - { - // @ts-expect-error - testing unknown event type - type: 'io.rocketchat.custom', - room_id: roomId, - sender: bob, - // @ts-expect-error - testing unknown event type - content: { custom_field: 'test_value' }, - ...getDefaultFields(), - }, - roomVersion, - ); + expect(stateService.handlePdu(membershipEventJoin2)).rejects.toThrow(); + expect(membershipEventJoin2.rejected).toBeTrue(); + expect(membershipEventJoin2.rejectCode).toBe(RejectCodes.AuthError); + }); + + it('11 should soft fail events', async () => { + const { roomCreateEvent } = await createRoom('public'); + + // add a user + const bob = '@bob:example.com' as room.UserID; + await joinUser(roomCreateEvent.roomId, bob); + // ban bob now + const banBobEvent = await banUser( + roomCreateEvent.roomId, + bob, + roomCreateEvent.getContent().creator, + ); - // should not throw when processing custom event - await stateService.handlePdu(customEvent); - expect(customEvent.rejected).toBeFalsy(); + const state1 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state1.getUserMembership(bob)).toBe('ban'); - // verify custom event is saved in database - const savedCustomEvent = await eventRepository.findById( - customEvent.eventId, - ); - expect(savedCustomEvent).toBeDefined(); - // @ts-expect-error - testing unknown event type - expect(savedCustomEvent?.event.type).toBe('io.rocketchat.custom'); - - // test 2: unknown Matrix standard event as timeline event (m.poll.start) - const unknownMatrixEvent = await stateService.buildEvent( - { - // @ts-expect-error - testing unknown event type - type: 'm.poll.start', - room_id: roomId, - sender: bob, - // @ts-expect-error - testing unknown event type - content: { question: 'Test poll?' }, - ...getDefaultFields(), - }, - roomVersion, - ); + // now we try to make bob "leave", but set the depth manually to be before he was banned + // leave is a state event + const bobLeaveEvent = stripPreviousAndAuthEvents( + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: bob, + state_key: bob, + content: { membership: 'leave' }, + ...getDefaultFields(), + }, + roomCreateEvent.getContent().room_version, + ), + ); - // should not throw when processing unknown Matrix event - await stateService.handlePdu(unknownMatrixEvent); - expect(unknownMatrixEvent.rejected).toBeFalsy(); + const store = stateService._getStore( + roomCreateEvent.getContent() + .room_version as RoomVersion, + ); - // verify unknown Matrix event is saved in database - const savedUnknownEvent = await eventRepository.findById( - unknownMatrixEvent.eventId, - ); - expect(savedUnknownEvent).toBeDefined(); - // @ts-expect-error - testing unknown event type - expect(savedUnknownEvent?.event.type).toBe('m.poll.start'); - - // test 3: custom event as timeline event (com.example.*) - const anotherCustomEvent = await stateService.buildEvent( - { - // @ts-expect-error - testing unknown event type - type: 'com.example.test', - room_id: roomId, - sender: bob, - // @ts-expect-error - testing unknown event type - content: { data: 'example' }, - ...getDefaultFields(), - }, - roomVersion, - ); + const eventsBeforeBobWasBanned = await store.getEvents( + banBobEvent.getPreviousEventIds(), + ); - await stateService.handlePdu(anotherCustomEvent); - expect(anotherCustomEvent.rejected).toBeFalsy(); + const authEventsForBobBan = await store.getEvents( + banBobEvent.getAuthEventIds(), + ); // should be the same for bob - // verify it's saved - const savedExampleEvent = await eventRepository.findById( - anotherCustomEvent.eventId, - ); - expect(savedExampleEvent).toBeDefined(); - // @ts-expect-error - testing unknown event type - expect(savedExampleEvent?.event.type).toBe('com.example.test'); - }); + bobLeaveEvent.addPrevEvents(eventsBeforeBobWasBanned); + + // biome-ignore lint/complexity/noForEach: + authEventsForBobBan.forEach((e) => bobLeaveEvent.authedBy(e)); + + expect(stateService.handlePdu(bobLeaveEvent)).rejects.toThrow(); + expect(bobLeaveEvent.rejected).toBeTrue(); + expect(bobLeaveEvent.rejectCode).toBe(RejectCodes.AuthError); + }); - it('01#arriving_late should fix state in case of older event arriving late', async () => { - const { roomCreateEvent, powerLevelEvent, roomNameEvent } = - await createRoom('public'); + it('01#arriving_late should fix state in case of older event arriving late', async () => { + const { roomCreateEvent, powerLevelEvent, roomNameEvent } = + await createRoom('public'); - const roomId = roomCreateEvent.roomId; + const roomId = roomCreateEvent.roomId; - // add a user - const bob = '@bob:example.com' as room.UserID; - await joinUser(roomCreateEvent.roomId, bob); + // add a user + const bob = '@bob:example.com' as room.UserID; + await joinUser(roomCreateEvent.roomId, bob); - const powerLevelContent = structuredClone(powerLevelEvent.getContent()); + const powerLevelContent = structuredClone(powerLevelEvent.getContent()); + + // we increase bob to 50 allowing room name change + + powerLevelContent.users[bob] = 50; + + const newPowerLevelEvent = + await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: roomCreateEvent.getContent() + .creator as room.UserID, + state_key: '', + content: powerLevelContent, + ...getDefaultFields(), + }, + PersistentEventFactory.defaultRoomVersion, + ); - // we increase bob to 50 allowing room name change + await stateService.handlePdu(newPowerLevelEvent); - powerLevelContent.users[bob] = 50; + const state1 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state1.powerLevels?.users[bob]).toBe(50); - const newPowerLevelEvent = - await stateService.buildEvent<'m.room.power_levels'>( + // now we make bob change the room name, this should work + const newRoomName = 'New Room Name'; + const roomNameEventByBob = await stateService.buildEvent<'m.room.name'>( { - type: 'm.room.power_levels', room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent() - .creator as room.UserID, + sender: bob, + content: { name: newRoomName }, state_key: '', - content: powerLevelContent, + type: 'm.room.name', ...getDefaultFields(), }, PersistentEventFactory.defaultRoomVersion, ); - await stateService.handlePdu(newPowerLevelEvent); + await stateService.handlePdu(roomNameEventByBob); - const state1 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state1.powerLevels?.users[bob]).toBe(50); - - // now we make bob change the room name, this should work - const newRoomName = 'New Room Name'; - const roomNameEventByBob = await stateService.buildEvent<'m.room.name'>( - { - room_id: roomCreateEvent.roomId, - sender: bob, - content: { name: newRoomName }, - state_key: '', - type: 'm.room.name', - ...getDefaultFields(), - }, - PersistentEventFactory.defaultRoomVersion, - ); - - await stateService.handlePdu(roomNameEventByBob); + expect((await stateService.getLatestRoomState2(roomId)).name).toBe( + newRoomName, + ); - expect((await stateService.getLatestRoomState2(roomId)).name).toBe( - newRoomName, - ); + // add another delta so both events point to the same state + const state2 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); + expect(state2.name).toBe(newRoomName); - // add another delta so both events point to the same state - const state2 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - expect(state2.name).toBe(newRoomName); + // we now mimick sending a ban event for bob, but before the power level event was sent + const banBobEvent = stripPreviousAndAuthEvents( + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: roomCreateEvent.getContent() + .creator as room.UserID, + state_key: bob, + content: { membership: 'ban' }, + ...getDefaultFields(), + }, + roomCreateEvent.getContent() + .room_version as RoomVersion, + ), + ); - // we now mimick sending a ban event for bob, but before the power level event was sent - const banBobEvent = stripPreviousAndAuthEvents( - await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent() - .creator as room.UserID, - state_key: bob, - content: { membership: 'ban' }, - ...getDefaultFields(), - }, + const store = stateService._getStore( roomCreateEvent.getContent() .room_version as RoomVersion, - ), - ); - - const store = stateService._getStore( - roomCreateEvent.getContent() - .room_version as RoomVersion, - ); + ); - const eventsBeforePowerLevel = await store.getEvents( - newPowerLevelEvent.getPreviousEventIds(), - ); + const eventsBeforePowerLevel = await store.getEvents( + newPowerLevelEvent.getPreviousEventIds(), + ); - banBobEvent.addPrevEvents(eventsBeforePowerLevel); + banBobEvent.addPrevEvents(eventsBeforePowerLevel); - const stateBeforePowerLevelEvent = - await stateService.getStateBeforeEvent(powerLevelEvent); + const stateBeforePowerLevelEvent = + await stateService.getStateBeforeEvent(powerLevelEvent); - for (const requiredAuthEvent of banBobEvent.getAuthEventStateKeys()) { - const authEvent = stateBeforePowerLevelEvent.get(requiredAuthEvent); - if (authEvent) { - banBobEvent.authedBy(authEvent); + for (const requiredAuthEvent of banBobEvent.getAuthEventStateKeys()) { + const authEvent = stateBeforePowerLevelEvent.get(requiredAuthEvent); + if (authEvent) { + banBobEvent.authedBy(authEvent); + } } - } - await stateService.handlePdu(banBobEvent); + await stateService.handlePdu(banBobEvent); - const state3 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); - - // console.log((stte3 as any).stateMap); + const state3 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); - expect(state3.name).toBe( - roomNameEvent.getContent().name, - ); // should set the state to right versions - }); + expect(state3.name).toBe( + roomNameEvent.getContent().name, + ); // should set the state to right versions + }); - it('02#arriving_late should fix state in case of older event arriving late', async () => { - const { roomCreateEvent } = await createRoom('public'); + it('02#arriving_late should fix state in case of older event arriving late', async () => { + const { roomCreateEvent } = await createRoom('public'); - // add a user - const bob = '@bob:example.com' as room.UserID; - await joinUser(roomCreateEvent.roomId, bob); + // add a user + const bob = '@bob:example.com' as room.UserID; + await joinUser(roomCreateEvent.roomId, bob); - const diego = '@diego:example.com'; - await joinUser(roomCreateEvent.roomId, diego); + const diego = '@diego:example.com'; + await joinUser(roomCreateEvent.roomId, diego); - const joinRuleInvite = stripPreviousAndAuthEvents( - await stateService.buildEvent<'m.room.join_rules'>( - { - room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent() - .creator as room.UserID, - content: { join_rule: 'invite' }, - type: 'm.room.join_rules', - state_key: '', - ...getDefaultFields(), - }, - PersistentEventFactory.defaultRoomVersion, - ), - ); + const joinRuleInvite = stripPreviousAndAuthEvents( + await stateService.buildEvent<'m.room.join_rules'>( + { + room_id: roomCreateEvent.roomId, + sender: roomCreateEvent.getContent() + .creator as room.UserID, + content: { join_rule: 'invite' }, + type: 'm.room.join_rules', + state_key: '', + ...getDefaultFields(), + }, + PersistentEventFactory.defaultRoomVersion, + ), + ); - // we will NOT fill or send the event yet. + // we will NOT fill or send the event yet. - const randomUser1 = '@random1:example.com'; - const randomUserJoinEvent = await joinUser( - roomCreateEvent.roomId, - randomUser1, - ); + const randomUser1 = '@random1:example.com'; + const randomUserJoinEvent = await joinUser( + roomCreateEvent.roomId, + randomUser1, + ); - const randomUser2 = '@random2:example.com'; - await joinUser(roomCreateEvent.roomId, randomUser2); + const randomUser2 = '@random2:example.com'; + await joinUser(roomCreateEvent.roomId, randomUser2); - const state1 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); + const state1 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); - expect(state1.isUserInRoom(randomUser1)).toBe(true); - expect(state1.isUserInRoom(randomUser2)).toBe(true); + expect(state1.isUserInRoom(randomUser1)).toBe(true); + expect(state1.isUserInRoom(randomUser2)).toBe(true); - // join rule was changed before randomuser joined - const store = stateService._getStore(state1.version); + // join rule was changed before randomuser joined + const store = stateService._getStore(state1.version); - const previousEventIdsForJoinRule = - randomUserJoinEvent.getPreviousEventIds(); - const previousEventsForJoinRule = await store.getEvents( - previousEventIdsForJoinRule, - ); - joinRuleInvite.addPrevEvents(previousEventsForJoinRule); - - // while the join doesn't affect the auth events for joinrule, still doing it this way as an example for the correct way - const stateBeforeRandomUserJoin = - await stateService.getStateBeforeEvent(randomUserJoinEvent); - for (const requiredAuthEvent of joinRuleInvite.getAuthEventStateKeys()) { - const authEvent = stateBeforeRandomUserJoin.get(requiredAuthEvent); - if (authEvent) { - joinRuleInvite.authedBy(authEvent); + const previousEventIdsForJoinRule = + randomUserJoinEvent.getPreviousEventIds(); + const previousEventsForJoinRule = await store.getEvents( + previousEventIdsForJoinRule, + ); + joinRuleInvite.addPrevEvents(previousEventsForJoinRule); + + // while the join doesn't affect the auth events for joinrule, still doing it this way as an example for the correct way + const stateBeforeRandomUserJoin = + await stateService.getStateBeforeEvent(randomUserJoinEvent); + for (const requiredAuthEvent of joinRuleInvite.getAuthEventStateKeys()) { + const authEvent = stateBeforeRandomUserJoin.get(requiredAuthEvent); + if (authEvent) { + joinRuleInvite.authedBy(authEvent); + } } - } - - await stateService.handlePdu(joinRuleInvite); - - // const _state2 = await stateService.findStateAtEvent(joinRuleInvite.eventId); - // const state2 = new RoomState(_state2); + await stateService.handlePdu(joinRuleInvite); - // console.log('state', [..._state2.entries()]); + // const _state2 = await stateService.findStateAtEvent(joinRuleInvite.eventId); - const state2 = await stateService.getLatestRoomState2( - roomCreateEvent.roomId, - ); + // const state2 = new RoomState(_state2); - expect(state2.isUserInRoom(randomUser1)).toBe(false); - expect(state2.isUserInRoom(randomUser2)).toBe(false); - }); + const state2 = await stateService.getLatestRoomState2( + roomCreateEvent.roomId, + ); - // it('should minimize amount of required state resolutions', async () => { - // const spy = spyOn(room, 'resolveStateV2Plus'); - // // once an old event gets to us, we run state res. - // // assume the state res stored new deltas randomly, would cause forward extremeties to behave the same way. - // // the next new event we will receive, should be pointing to the latest state, for that to happenm - // // we must make sure we associate latest state with the latest event we could have at that time. - // // --- - // // create a room - // // do stuff - // // send an out of order event - // // now send a normal event - // // see if it triggered stat res or not - // // --- - // const { roomCreateEvent, powerLevelEvent } = await createRoom('public'); - - // // add a user - // const bob = '@bob:example.com'; - // await joinUser(roomCreateEvent.roomId, bob); - - // const powerLevelContent = structuredClone( - // powerLevelEvent.getContent(), - // ); - - // // we increase bob to 50 allowing room name change - - // powerLevelContent.users[bob] = 50; - - // const newPowerLevelEvent = PersistentEventFactory.newPowerLevelEvent( - // roomCreateEvent.roomId, - // roomCreateEvent.getContent().creator, - // powerLevelContent, - // PersistentEventFactory.defaultRoomVersion, - // ); - - // await Promise.all([ - // stateService.addAuthEvents(newPowerLevelEvent), - // stateService.addPrevEvents(newPowerLevelEvent), - // ]); - - // // to test the out of order saving, and state res being triggered, we need to force multiple state deltas to be created. - // // one will be the current one that triggers a new delta. - // // we'll also need an event that is supposed to be valid but was rejected previouysly - // // trhe power level event is supposed to "allow" bob to change the room name - // // but we processed/received the name change first. - - // // increase the graph - // await Promise.all([ - // joinUser(roomCreateEvent.roomId, '@random1:example.com'), - // joinUser(roomCreateEvent.roomId, '@random2:example.com'), - // joinUser(roomCreateEvent.roomId, '@random3:example.com'), - // joinUser(roomCreateEvent.roomId, '@random4:example.com'), - // joinUser(roomCreateEvent.roomId, '@random5:example.com'), - // joinUser(roomCreateEvent.roomId, '@random6:example.com'), - // joinUser(roomCreateEvent.roomId, '@random7:example.com'), - // joinUser(roomCreateEvent.roomId, '@random8:example.com'), - // joinUser(roomCreateEvent.roomId, '@random9:example.com'), - // ]); - - // // bob tries to change the room name, but at this point the power level event has not been "sent" yet - - // const bobChangeNameEvent = PersistentEventFactory.newRoomNameEvent( - // roomCreateEvent.roomId, - // bob, - // 'Bob Changed Name', - // PersistentEventFactory.defaultRoomVersion, - // ); - - // await Promise.all([ - // stateService.addAuthEvents(bobChangeNameEvent), - // stateService.addPrevEvents(bobChangeNameEvent), - // ]); - - // await stateService.handlePdu(bobChangeNameEvent); - - // expect(bobChangeNameEvent.rejected).toBeTrue(); - // expect(bobChangeNameEvent.rejectCode).toBe(RejectReason.AuthError); - // expect(bobChangeNameEvent.rejectedBy).toBe(powerLevelEvent.eventId); - - // // ^ rejected event, will not participate in graph, thus nextEventId stays the same (for our immplementatioon) - - // // since name change event was rejected, it did not create a new state delta - // // need to send another to now create one - // }); - - it('should list correct servers for a room', async () => { - const { roomCreateEvent } = await createRoom('public'); - - // add a user - const bob = '@bob:example.com'; - await joinUser(roomCreateEvent.roomId, bob); - - const diego = '@diego:example.com'; - await joinUser(roomCreateEvent.roomId, diego); - - const servers = await stateService.getServersInRoom(roomCreateEvent.roomId); - expect(servers).toContain('example.com'); - expect(servers.length).toBe(1); - - const remoteUser = '@alice:remote.com'; - await joinUser(roomCreateEvent.roomId, remoteUser); - - const servers2 = await stateService.getServersInRoom( - roomCreateEvent.roomId, - ); - expect(servers2).toContain('example.com'); - expect(servers2).toContain('remote.com'); - expect(servers2.length).toBe(2); + expect(state2.isUserInRoom(randomUser1)).toBe(false); + expect(state2.isUserInRoom(randomUser2)).toBe(false); + }); - // now leave the remote user - await leaveUser(roomCreateEvent.roomId, remoteUser); + // it('should minimize amount of required state resolutions', async () => { + // const spy = spyOn(room, 'resolveStateV2Plus'); + // // once an old event gets to us, we run state res. + // // assume the state res stored new deltas randomly, would cause forward extremeties to behave the same way. + // // the next new event we will receive, should be pointing to the latest state, for that to happenm + // // we must make sure we associate latest state with the latest event we could have at that time. + // // --- + // // create a room + // // do stuff + // // send an out of order event + // // now send a normal event + // // see if it triggered stat res or not + // // --- + // const { roomCreateEvent, powerLevelEvent } = await createRoom('public'); + + // // add a user + // const bob = '@bob:example.com'; + // await joinUser(roomCreateEvent.roomId, bob); + + // const powerLevelContent = structuredClone( + // powerLevelEvent.getContent(), + // ); + + // // we increase bob to 50 allowing room name change + + // powerLevelContent.users[bob] = 50; + + // const newPowerLevelEvent = PersistentEventFactory.newPowerLevelEvent( + // roomCreateEvent.roomId, + // roomCreateEvent.getContent().creator, + // powerLevelContent, + // PersistentEventFactory.defaultRoomVersion, + // ); + + // await Promise.all([ + // stateService.addAuthEvents(newPowerLevelEvent), + // stateService.addPrevEvents(newPowerLevelEvent), + // ]); + + // // to test the out of order saving, and state res being triggered, we need to force multiple state deltas to be created. + // // one will be the current one that triggers a new delta. + // // we'll also need an event that is supposed to be valid but was rejected previouysly + // // trhe power level event is supposed to "allow" bob to change the room name + // // but we processed/received the name change first. + + // // increase the graph + // await Promise.all([ + // joinUser(roomCreateEvent.roomId, '@random1:example.com'), + // joinUser(roomCreateEvent.roomId, '@random2:example.com'), + // joinUser(roomCreateEvent.roomId, '@random3:example.com'), + // joinUser(roomCreateEvent.roomId, '@random4:example.com'), + // joinUser(roomCreateEvent.roomId, '@random5:example.com'), + // joinUser(roomCreateEvent.roomId, '@random6:example.com'), + // joinUser(roomCreateEvent.roomId, '@random7:example.com'), + // joinUser(roomCreateEvent.roomId, '@random8:example.com'), + // joinUser(roomCreateEvent.roomId, '@random9:example.com'), + // ]); + + // // bob tries to change the room name, but at this point the power level event has not been "sent" yet + + // const bobChangeNameEvent = PersistentEventFactory.newRoomNameEvent( + // roomCreateEvent.roomId, + // bob, + // 'Bob Changed Name', + // PersistentEventFactory.defaultRoomVersion, + // ); + + // await Promise.all([ + // stateService.addAuthEvents(bobChangeNameEvent), + // stateService.addPrevEvents(bobChangeNameEvent), + // ]); + + // await stateService.handlePdu(bobChangeNameEvent); + + // expect(bobChangeNameEvent.rejected).toBeTrue(); + // expect(bobChangeNameEvent.rejectCode).toBe(RejectReason.AuthError); + // expect(bobChangeNameEvent.rejectedBy).toBe(powerLevelEvent.eventId); + + // // ^ rejected event, will not participate in graph, thus nextEventId stays the same (for our immplementatioon) + + // // since name change event was rejected, it did not create a new state delta + // // need to send another to now create one + // }); + + it('should list correct servers for a room', async () => { + const { roomCreateEvent } = await createRoom('public'); + + // add a user + const bob = '@bob:example.com'; + await joinUser(roomCreateEvent.roomId, bob); + + const diego = '@diego:example.com'; + await joinUser(roomCreateEvent.roomId, diego); + + const servers = await stateService.getServersInRoom( + roomCreateEvent.roomId, + ); + expect(servers).toContain('example.com'); + expect(servers.length).toBe(1); - const servers3 = await stateService.getServersInRoom( - roomCreateEvent.roomId, - ); - expect(servers3).toContain('example.com'); - expect(servers3.length).toBe(1); + const remoteUser = '@alice:remote.com'; + await joinUser(roomCreateEvent.roomId, remoteUser); - // now add her again - await joinUser(roomCreateEvent.roomId, remoteUser); + const servers2 = await stateService.getServersInRoom( + roomCreateEvent.roomId, + ); + expect(servers2).toContain('example.com'); + expect(servers2).toContain('remote.com'); + expect(servers2.length).toBe(2); - const servers4 = await stateService.getServersInRoom( - roomCreateEvent.roomId, - ); - expect(servers4).toContain('example.com'); - expect(servers4).toContain('remote.com'); - expect(servers4.length).toBe(2); - }); + // now leave the remote user + await leaveUser(roomCreateEvent.roomId, remoteUser); - it('should allow previously rejected events through multiple state resolutions', async () => { - const { roomCreateEvent } = await createRoom('public'); - const roomId = roomCreateEvent.roomId; - const roomVersion = roomCreateEvent.getContent().room_version; - const creator = roomCreateEvent.getContent().creator as room.UserID; + const servers3 = await stateService.getServersInRoom( + roomCreateEvent.roomId, + ); + expect(servers3).toContain('example.com'); + expect(servers3.length).toBe(1); - const referenceDepthEvent = await joinUser(roomId, '@dummy:example.com'); + // now add her again + await joinUser(roomCreateEvent.roomId, remoteUser); - // try to join - const bob = '@bob:example.com'; - const bobJoinEvent = await joinUser(roomId, bob); - expect(bobJoinEvent.rejected).toBeFalse(); + const servers4 = await stateService.getServersInRoom( + roomCreateEvent.roomId, + ); + expect(servers4).toContain('example.com'); + expect(servers4).toContain('remote.com'); + expect(servers4.length).toBe(2); + }); - const joinRuleEvent = await copyDepth( - referenceDepthEvent, - await stateService.buildEvent<'m.room.join_rules'>( - { - type: 'm.room.join_rules', - content: { join_rule: 'invite' }, - room_id: roomId, - state_key: '', - sender: creator, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + it('should allow previously rejected events through multiple state resolutions', async () => { + const { roomCreateEvent } = await createRoom('public'); + const roomId = roomCreateEvent.roomId; + const roomVersion = roomCreateEvent.getContent().room_version; + const creator = roomCreateEvent.getContent().creator as room.UserID; - await stateService.handlePdu(joinRuleEvent); + const referenceDepthEvent = await joinUser(roomId, '@dummy:example.com'); - // should have triggered a state res, causing bob to no longer be part of the room - const state1 = await stateService.getLatestRoomState2(roomId); + // try to join + const bob = '@bob:example.com'; + const bobJoinEvent = await joinUser(roomId, bob); + expect(bobJoinEvent.rejected).toBeFalse(); - expect(state1.isUserInRoom(bob)).toBeFalse(); + const joinRuleEvent = await copyDepth( + referenceDepthEvent, + await stateService.buildEvent<'m.room.join_rules'>( + { + type: 'm.room.join_rules', + content: { join_rule: 'invite' }, + room_id: roomId, + state_key: '', + sender: creator, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - // but what if join rule were to be immediately switched back? - const joinRulePublicEvent = await copyDepth( - joinRuleEvent, - await stateService.buildEvent<'m.room.join_rules'>( - { - type: 'm.room.join_rules', - sender: creator, - state_key: '', - room_id: roomId, - content: { join_rule: 'public' }, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + await stateService.handlePdu(joinRuleEvent); - await stateService.handlePdu(joinRulePublicEvent); + // should have triggered a state res, causing bob to no longer be part of the room + const state1 = await stateService.getLatestRoomState2(roomId); - const state2 = await stateService.getLatestRoomState2(roomId); + expect(state1.isUserInRoom(bob)).toBeFalse(); - expect(state2.isPublic()).toBeTrue(); - expect(state2.isUserInRoom(bob)).toBeTrue(); - }); + // but what if join rule were to be immediately switched back? + const joinRulePublicEvent = await copyDepth( + joinRuleEvent, + await stateService.buildEvent<'m.room.join_rules'>( + { + type: 'm.room.join_rules', + sender: creator, + state_key: '', + room_id: roomId, + content: { join_rule: 'public' }, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - it('should consider previously rejected event as part of state if new out of order event allows it', async () => { - const { roomCreateEvent } = await createRoom('public'); - const roomId = roomCreateEvent.roomId; - const creator = roomCreateEvent.getContent().creator as room.UserID; - const roomVersion = roomCreateEvent.version; - - // make bob join - const bob = '@bob:example.com'; - const bobJoinEvent = await joinUser(roomId, bob); - const state1 = await stateService.getLatestRoomState2(roomId); - expect(state1.isUserInRoom(bob)).toBeTrue(); - - // change join rule to private - const joinRuleInvite = await copyDepth( - bobJoinEvent, - await stateService.buildEvent<'m.room.join_rules'>( - { - type: 'm.room.join_rules', - sender: creator, - state_key: '', - room_id: roomId, - content: { join_rule: 'invite' }, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + await stateService.handlePdu(joinRulePublicEvent); - await stateService.handlePdu(joinRuleInvite); + const state2 = await stateService.getLatestRoomState2(roomId); - // bob not in room - const state2 = await stateService.getLatestRoomState2(roomId); - expect(state2.isInviteOnly()).toBeTrue(); - expect(state2.isUserInRoom(bob)).toBeFalse(); - //.... + expect(state2.isPublic()).toBeTrue(); + expect(state2.isUserInRoom(bob)).toBeTrue(); + }); - const joinRulePublic = await copyDepth( - bobJoinEvent, - await stateService.buildEvent<'m.room.join_rules'>( - { - type: 'm.room.join_rules', - sender: creator, - state_key: '', - room_id: roomId, - content: { join_rule: 'public' }, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + it('should consider previously rejected event as part of state if new out of order event allows it', async () => { + const { roomCreateEvent } = await createRoom('public'); + const roomId = roomCreateEvent.roomId; + const creator = roomCreateEvent.getContent().creator as room.UserID; + const roomVersion = roomCreateEvent.version; - // bob allowed to be in room again - await stateService.handlePdu(joinRulePublic); - // - const state3 = await stateService.getLatestRoomState2(roomId); - expect(state3.isPublic()).toBeTrue(); - expect(state3.isUserInRoom(bob)).toBeTrue(); - }); + // make bob join + const bob = '@bob:example.com'; + const bobJoinEvent = await joinUser(roomId, bob); + const state1 = await stateService.getLatestRoomState2(roomId); + expect(state1.isUserInRoom(bob)).toBeTrue(); + + // change join rule to private + const joinRuleInvite = await copyDepth( + bobJoinEvent, + await stateService.buildEvent<'m.room.join_rules'>( + { + type: 'm.room.join_rules', + sender: creator, + state_key: '', + room_id: roomId, + content: { join_rule: 'invite' }, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - it('should build the correct latest state even if event is not accepted', async () => { - const don = '@don:example.com' as room.UserID; + await stateService.handlePdu(joinRuleInvite); + + // bob not in room + const state2 = await stateService.getLatestRoomState2(roomId); + expect(state2.isInviteOnly()).toBeTrue(); + expect(state2.isUserInRoom(bob)).toBeFalse(); + //.... + + const joinRulePublic = await copyDepth( + bobJoinEvent, + await stateService.buildEvent<'m.room.join_rules'>( + { + type: 'm.room.join_rules', + sender: creator, + state_key: '', + room_id: roomId, + content: { join_rule: 'public' }, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - const { roomCreateEvent, roomNameEvent } = await createRoom('public', { - [don]: 50, + // bob allowed to be in room again + await stateService.handlePdu(joinRulePublic); + // + const state3 = await stateService.getLatestRoomState2(roomId); + expect(state3.isPublic()).toBeTrue(); + expect(state3.isUserInRoom(bob)).toBeTrue(); }); - const roomId = roomCreateEvent.roomId; - const roomVersion = roomCreateEvent.version; - const creator = roomCreateEvent.getContent().creator as room.UserID; + it('should build the correct latest state even if event is not accepted', async () => { + const don = '@don:example.com' as room.UserID; - await joinUser(roomId, don); + const { roomCreateEvent, roomNameEvent } = await createRoom('public', { + [don]: 50, + }); - // prepare a room name event - const roomName = stripPreviousEvents( - // auth events stay the same - await stateService.buildEvent<'m.room.name'>( - { - type: 'm.room.name', - sender: don, - state_key: '', - content: { name: 'new name' }, - room_id: roomId, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + const roomId = roomCreateEvent.roomId; + const roomVersion = roomCreateEvent.version; + const creator = roomCreateEvent.getContent().creator as room.UserID; - const stateResSpy = spyOn(room, 'resolveStateV2Plus'); + await joinUser(roomId, don); - // two state events creating two chains - // 1. normal join EVent - const bob = '@bob:example.com'; - const bobJoin = await joinUser(roomId, bob); + // prepare a room name event + const roomName = stripPreviousEvents( + // auth events stay the same + await stateService.buildEvent<'m.room.name'>( + { + type: 'm.room.name', + sender: don, + state_key: '', + content: { name: 'new name' }, + room_id: roomId, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - expect(stateResSpy).toHaveBeenCalledTimes(0); + const stateResSpy = spyOn(room, 'resolveStateV2Plus'); - // 2. another normal join event but same depth - const donBan = await copyDepth( - bobJoin, - await stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - sender: creator, - state_key: don, - content: { membership: 'ban' }, - room_id: roomId, - ...getDefaultFields(), - }, - roomVersion, - ), - ); + // two state events creating two chains + // 1. normal join EVent + const bob = '@bob:example.com'; + const bobJoin = await joinUser(roomId, bob); - await stateService.handlePdu(donBan); + expect(stateResSpy).toHaveBeenCalledTimes(0); - expect(stateResSpy).toHaveBeenCalledTimes(1); // soft fail check + // 2. another normal join event but same depth + const donBan = await copyDepth( + bobJoin, + await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + sender: creator, + state_key: don, + content: { membership: 'ban' }, + room_id: roomId, + ...getDefaultFields(), + }, + roomVersion, + ), + ); - // stateResSpy.mockReset(); + await stateService.handlePdu(donBan); - const state1 = await stateService.getLatestRoomState2(roomId); - expect(state1.isUserInRoom(bob)).toBeTrue(); - expect(state1.getUserMembership(don)).toBe('ban'); + expect(stateResSpy).toHaveBeenCalledTimes(1); // soft fail check - // a new event that - roomName.addPrevEvents([bobJoin, donBan]); + // stateResSpy.mockReset(); - expect(stateService.handlePdu(roomName)).rejects.toThrowError(); + const state1 = await stateService.getLatestRoomState2(roomId); + expect(state1.isUserInRoom(bob)).toBeTrue(); + expect(state1.getUserMembership(don)).toBe('ban'); - const state2 = await stateService.getStateAtEvent(roomName); - // must not be new name - expect(state2.get(roomName.getUniqueStateIdentifier())).toHaveProperty( - 'eventId', - roomNameEvent.eventId, - ); - }); + // a new event that + roomName.addPrevEvents([bobJoin, donBan]); - // removing bias from own code - it('should fetch the right state ids', async () => { - const toEventBase = (pdu: room.Pdu) => { - return PersistentEventFactory.createFromRawEvent(pdu, '10'); - }; + expect(stateService.handlePdu(roomName)).rejects.toThrowError(); - const createEvent = { - type: 'm.room.create', - state_key: '', - content: { - room_version: '10', - creator: '@debdut:rc1.tunnel.dev.rocket.chat', - }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - origin_server_ts: 1759757583361, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - prev_events: [], - auth_events: [], - depth: 0, - hashes: { - sha256: 'FIP9UiTEzoBmUJDr6wX5e2N6Xl0pg68xC8OSFXfbNac', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'lMv8+0wXgFSGRtHgBNhu8T8xkcCc6SLZfJwcLGjFIgaXAdAxMjx7HiZNv+JuDWl8qEbgdisuTkPzUTmdsgxgDQ', + const state2 = await stateService.getStateAtEvent(roomName); + // must not be new name + expect(state2.get(roomName.getUniqueStateIdentifier())).toHaveProperty( + 'eventId', + roomNameEvent.eventId, + ); + }); + + // removing bias from own code + it('should fetch the right state ids', async () => { + const toEventBase = (pdu: room.Pdu) => { + return PersistentEventFactory.createFromRawEvent(pdu, '10'); + }; + + const createEvent = { + type: 'm.room.create', + state_key: '', + content: { + room_version: '10', + creator: '@debdut:rc1.tunnel.dev.rocket.chat', }, - }, - unsigned: {}, - } satisfies room.Pdu; + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + origin_server_ts: 1759757583361, + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + prev_events: [], + auth_events: [], + depth: 0, + hashes: { + sha256: 'FIP9UiTEzoBmUJDr6wX5e2N6Xl0pg68xC8OSFXfbNac', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'lMv8+0wXgFSGRtHgBNhu8T8xkcCc6SLZfJwcLGjFIgaXAdAxMjx7HiZNv+JuDWl8qEbgdisuTkPzUTmdsgxgDQ', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(createEvent)); + await stateService.handlePdu(toEventBase(createEvent)); - const memberEvent = { - type: 'm.room.member', - content: { - membership: 'join', - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - auth_events: [ - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - depth: 1, - prev_events: [ - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - origin_server_ts: 1759757583403, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'xxHM8Wz5s70Z24XGVlQuQlP6wu4WKHKrWJkX+VZsN7Y', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'LCl4QANTD9cSDpIrXj3bv7nJxs9x7QA4y6tZl49//C3CJJiGzZUfjGG3dH11RcSD2esTNSYJwQAbKqNGiWe4Ag', + const memberEvent = { + type: 'm.room.member', + content: { + membership: 'join', }, - }, - unsigned: {}, - } satisfies room.Pdu; + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + auth_events: [ + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + depth: 1, + prev_events: [ + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + origin_server_ts: 1759757583403, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'xxHM8Wz5s70Z24XGVlQuQlP6wu4WKHKrWJkX+VZsN7Y', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'LCl4QANTD9cSDpIrXj3bv7nJxs9x7QA4y6tZl49//C3CJJiGzZUfjGG3dH11RcSD2esTNSYJwQAbKqNGiWe4Ag', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(memberEvent)); + await stateService.handlePdu(toEventBase(memberEvent)); - const name = { - type: 'm.room.name', - content: { - name: 'a', - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '', - auth_events: [ - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - depth: 2, - prev_events: [ - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - ] as EventID[], - origin_server_ts: 1759757583427, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'kSKUQ1qEW1kMBFFT8T738BJRpgKQaZoeX9K/RcgOxx8', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'GSgPxsTF7VnbtGEGmwhH7F/Ets4R15BJpl1NjWi+SdwkVp7nvcQm/hKUNY803QlBNYur5OcLIi47DkxJ2Cg1Cw', + const name = { + type: 'm.room.name', + content: { + name: 'a', }, - }, - unsigned: {}, - } satisfies room.Pdu; + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '', + auth_events: [ + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + depth: 2, + prev_events: [ + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + ] as EventID[], + origin_server_ts: 1759757583427, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'kSKUQ1qEW1kMBFFT8T738BJRpgKQaZoeX9K/RcgOxx8', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'GSgPxsTF7VnbtGEGmwhH7F/Ets4R15BJpl1NjWi+SdwkVp7nvcQm/hKUNY803QlBNYur5OcLIi47DkxJ2Cg1Cw', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(name)); + await stateService.handlePdu(toEventBase(name)); - const powerLevel = { - type: 'm.room.power_levels', - content: { - users: { - '@debdut:rc1.tunnel.dev.rocket.chat': 100, + const powerLevel = { + type: 'm.room.power_levels', + content: { + users: { + '@debdut:rc1.tunnel.dev.rocket.chat': 100, + }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, }, - users_default: 0, - events: {}, - events_default: 0, - state_default: 50, - ban: 50, - kick: 50, - redact: 50, - invite: 50, - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '', - auth_events: [ - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - depth: 3, - prev_events: [ - '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', - ] as EventID[], - origin_server_ts: 1759757583446, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'tyTsDppTjJWviE4U2dU5SIhofkWOg1mM50m43dmJUWk', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'Tftd0/LAn8NaEcrGYb5nXp69nbSyVBNqlDuqSfq3XbAOlWSRZIiP7/Zm4RZmrdZ6zZgjvDABD+TrCiRFccxdDg', + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '', + auth_events: [ + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + depth: 3, + prev_events: [ + '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', + ] as EventID[], + origin_server_ts: 1759757583446, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'tyTsDppTjJWviE4U2dU5SIhofkWOg1mM50m43dmJUWk', }, - }, - unsigned: {}, - } satisfies room.Pdu; + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'Tftd0/LAn8NaEcrGYb5nXp69nbSyVBNqlDuqSfq3XbAOlWSRZIiP7/Zm4RZmrdZ6zZgjvDABD+TrCiRFccxdDg', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(powerLevel)); + await stateService.handlePdu(toEventBase(powerLevel)); - const joinRule = { - type: 'm.room.join_rules', - content: { - join_rule: 'public', - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '', - auth_events: [ - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - depth: 4, - prev_events: [ - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - ] as EventID[], - origin_server_ts: 1759757583461, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'jt3bItyFElVVoEOJWaqtNf93dzpWi4D8kx6+ApABA6c', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - '4yNSSn29xebrwW2LpM+P7qGgHhpNFbmIFrvKQw7sN0qRHNaLIx7mlwiFyfm2P4gdHaiIV+6WyeueH+QtPJejAA', + const joinRule = { + type: 'm.room.join_rules', + content: { + join_rule: 'public', }, - }, - unsigned: {}, - } satisfies room.Pdu; + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '', + auth_events: [ + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + depth: 4, + prev_events: [ + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + ] as EventID[], + origin_server_ts: 1759757583461, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'jt3bItyFElVVoEOJWaqtNf93dzpWi4D8kx6+ApABA6c', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + '4yNSSn29xebrwW2LpM+P7qGgHhpNFbmIFrvKQw7sN0qRHNaLIx7mlwiFyfm2P4gdHaiIV+6WyeueH+QtPJejAA', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(joinRule)); + await stateService.handlePdu(toEventBase(joinRule)); - const alias = { - type: 'm.room.canonical_alias', - content: { - alias: '#a:rc1.tunnel.dev.rocket.chat', - alt_aliases: [], - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '', - auth_events: [ - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ] as EventID[], - depth: 5, - prev_events: [ - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - ] as EventID[], - origin_server_ts: 1759757583476, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'hvUo6j/yFIjhst7AJSgeMFKGtk4MuPAROKDdDv/Eb5c', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'HwCvEEM4l7HQDiHVyoNQOgsPuKR4Q7ZCfaD025XZ+rv9AG2Gu8rTGJ9Bvtq8oKSCgtqvNrPplLdk/KVow7ZXCA', + const alias = { + type: 'm.room.canonical_alias', + content: { + alias: '#a:rc1.tunnel.dev.rocket.chat', + alt_aliases: [], }, - }, - unsigned: {}, - } satisfies room.Pdu; + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '', + auth_events: [ + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ] as EventID[], + depth: 5, + prev_events: [ + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + ] as EventID[], + origin_server_ts: 1759757583476, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'hvUo6j/yFIjhst7AJSgeMFKGtk4MuPAROKDdDv/Eb5c', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'HwCvEEM4l7HQDiHVyoNQOgsPuKR4Q7ZCfaD025XZ+rv9AG2Gu8rTGJ9Bvtq8oKSCgtqvNrPplLdk/KVow7ZXCA', + }, + }, + unsigned: {}, + } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(alias)); + await stateService.handlePdu(toEventBase(alias)); - const invite = { - type: 'm.room.member', - content: { - membership: 'invite', - }, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - state_key: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, - auth_events: [ - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - ] as EventID[], - depth: 6, - prev_events: [ - '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', - ] as EventID[], - origin_server_ts: 1759757778902, - sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'fcw6kOo6W9yufNeeLB9QI+WLz78ED/QvMNbSut0sXMM', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - 'hoNC177zwdlYHURCAsavTPVYsBJJMINeuVCsbZjFiBFuO4SEaMEmTU/5Ht0KADE/XwHCOeGg4xB9sD9L+yeWDQ', + const invite = { + type: 'm.room.member', + content: { + membership: 'invite', }, - 'syn1.tunnel.dev.rocket.chat': { - 'ed25519:a_FAET': - 'hpFY8m5g1e4s/0VrV8fEP3hIsvn1sh68ZfXaUrV5wpMSFwAzZaC5wW5UxwHAopUTDNfNRnR1lANti6a9ddUFBw', + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + state_key: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, + auth_events: [ + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + ] as EventID[], + depth: 6, + prev_events: [ + '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', + ] as EventID[], + origin_server_ts: 1759757778902, + sender: '@debdut:rc1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'fcw6kOo6W9yufNeeLB9QI+WLz78ED/QvMNbSut0sXMM', }, - }, - unsigned: { - invite_room_state: [ - { - content: { - alias: '#a:rc1.tunnel.dev.rocket.chat', - alt_aliases: [], - }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '', - type: 'm.room.canonical_alias', + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + 'hoNC177zwdlYHURCAsavTPVYsBJJMINeuVCsbZjFiBFuO4SEaMEmTU/5Ht0KADE/XwHCOeGg4xB9sD9L+yeWDQ', }, - { - content: { - join_rule: 'public', - }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '', - type: 'm.room.join_rules', + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'hpFY8m5g1e4s/0VrV8fEP3hIsvn1sh68ZfXaUrV5wpMSFwAzZaC5wW5UxwHAopUTDNfNRnR1lANti6a9ddUFBw', }, - { - content: { - users: { - '@debdut:rc1.tunnel.dev.rocket.chat': 100, + }, + unsigned: { + invite_room_state: [ + { + content: { + alias: '#a:rc1.tunnel.dev.rocket.chat', + alt_aliases: [], }, - users_default: 0, - events: {}, - events_default: 0, - state_default: 50, - ban: 50, - kick: 50, - redact: 50, - invite: 50, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '', + type: 'm.room.canonical_alias', }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '', - type: 'm.room.power_levels', - }, - { - content: { - name: 'a', + { + content: { + join_rule: 'public', + }, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '', + type: 'm.room.join_rules', }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '', - type: 'm.room.name', - }, - { - content: { - membership: 'join', + { + content: { + users: { + '@debdut:rc1.tunnel.dev.rocket.chat': 100, + }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '', + type: 'm.room.power_levels', }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '@debdut:rc1.tunnel.dev.rocket.chat', - type: 'm.room.member', - }, - { - content: { - room_version: '10', - creator: '@debdut:rc1.tunnel.dev.rocket.chat', + { + content: { + name: 'a', + }, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '', + type: 'm.room.name', }, - sender: '@debdut:rc1.tunnel.dev.rocket.chat', - state_key: '', - type: 'm.room.create', + { + content: { + membership: 'join', + }, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '@debdut:rc1.tunnel.dev.rocket.chat', + type: 'm.room.member', + }, + { + content: { + room_version: '10', + creator: '@debdut:rc1.tunnel.dev.rocket.chat', + }, + sender: '@debdut:rc1.tunnel.dev.rocket.chat', + state_key: '', + type: 'm.room.create', + }, + ], + }, + } satisfies room.Pdu; + + await stateService.handlePdu(toEventBase(invite)); + // join ecebnt + const ahJoin = { + type: 'm.room.member', + content: { + membership: 'join', + displayname: 'ah', + // @ts-ignore this has been fixed by rodrigo already in zod + avatar_url: null as unknown as undefined, + }, + sender: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + origin_server_ts: 1759757909955, + depth: 7, + prev_events: [ + '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', + ] as EventID[], + auth_events: [ + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + ] as EventID[], + // @ts-ignore + origin: 'syn1.tunnel.dev.rocket.chat', + unsigned: { + age: 2, + }, + state_key: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, + hashes: { + sha256: 'Hm9e72/DXfNsXJHoS/rGhIyBuNBnp3KcCEtuukMUrUk', + }, + signatures: { + 'rc1.tunnel.dev.rocket.chat': { + 'ed25519:0': + '5IyCqACOVFJ9h5HGbXWNOwuNpn2hdCRpDWMgmfnH11epIjCCGKNHLftaBPdiOLTSO9RyBixEtplphmgBazWCDQ', + }, + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'c6SYtgr3UoFu3OfxjF5Da+Sk2VgEBQxK3StPxC0CzXWrVg2AUkJyqL4RfbfAhMnRm3eDRFTHLZ1+WzllFJv6BA', }, + }, + } satisfies room.Pdu; + await stateService.handlePdu(toEventBase(ahJoin)); + const state = await stateService.getStateBeforeEvent(toEventBase(ahJoin)); + const pduIds = Array.from(state.values()) + .map((e) => e.eventId) + .sort(); + const expected = { + pdu_ids: [ + '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + ].sort() as EventID[], + auth_chain_ids: [ + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', ], - }, - } satisfies room.Pdu; - - await stateService.handlePdu(toEventBase(invite)); - // join ecebnt - const ahJoin = { - type: 'm.room.member', - content: { - membership: 'join', - displayname: 'ah', - // @ts-ignore this has been fixed by rodrigo already in zod - avatar_url: null as unknown as undefined, - }, - sender: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - origin_server_ts: 1759757909955, - depth: 7, - prev_events: [ - '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', - ] as EventID[], - auth_events: [ - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - ] as EventID[], - // @ts-ignore - origin: 'syn1.tunnel.dev.rocket.chat', - unsigned: { - age: 2, - }, - state_key: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, - hashes: { - sha256: 'Hm9e72/DXfNsXJHoS/rGhIyBuNBnp3KcCEtuukMUrUk', - }, - signatures: { - 'rc1.tunnel.dev.rocket.chat': { - 'ed25519:0': - '5IyCqACOVFJ9h5HGbXWNOwuNpn2hdCRpDWMgmfnH11epIjCCGKNHLftaBPdiOLTSO9RyBixEtplphmgBazWCDQ', + }; + expect(pduIds).toStrictEqual(expected.pdu_ids); + const expectedAfterMessageSent = { + pdu_ids: [ + '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + ].sort() as EventID[], + auth_chain_ids: [ + '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', + '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + ], + }; + const message = { + type: 'm.room.message', + content: { + body: '1', + // @ts-ignore are we missing this ? TODO: + 'm.mentions': {}, + msgtype: 'm.text', }, - 'syn1.tunnel.dev.rocket.chat': { - 'ed25519:a_FAET': - 'c6SYtgr3UoFu3OfxjF5Da+Sk2VgEBQxK3StPxC0CzXWrVg2AUkJyqL4RfbfAhMnRm3eDRFTHLZ1+WzllFJv6BA', + sender: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, + room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, + origin_server_ts: 1759760138291, + depth: 8, + prev_events: [ + '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', + ] as EventID[], + auth_events: [ + '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', + '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', + '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', + ] as EventID[], + origin: 'syn1.tunnel.dev.rocket.chat', + unsigned: { + age_ts: 1759760138291, }, - }, - } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(ahJoin)); - const state = await stateService.getStateBeforeEvent(toEventBase(ahJoin)); - const pduIds = Array.from(state.values()) - .map((e) => e.eventId) - .sort(); - const expected = { - pdu_ids: [ - '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - ].sort() as EventID[], - auth_chain_ids: [ - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ], - }; - expect(pduIds).toStrictEqual(expected.pdu_ids); - const expectedAfterMessageSent = { - pdu_ids: [ - '$3Ttw6n2x6EALDnf4Cm5BKtYrIfzlyuE0VMmfXI2j680', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - '$yvGHQAk_VvuInS5WsW3_w-mi5zLSC_ZWz724wSla_z4', - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - ].sort() as EventID[], - auth_chain_ids: [ - '$wWJBjUdHzAds-ZjpgwLQdDKpA3lQQLPkJuQCq-yUHQc', - '$_YhqI7eEy5XRK2FEtU1QjWAStEVfDhBiKbUmVh_ML_U', - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - '$Ka58p5BSdnjjmCPN22Erj6piAXRHIm9hQjXv1g5DeXw', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - ], - }; - const message = { - type: 'm.room.message', - content: { - body: '1', - // @ts-ignore are we missing this ? TODO: - 'm.mentions': {}, - msgtype: 'm.text', - }, - sender: '@ah:syn1.tunnel.dev.rocket.chat' as room.UserID, - room_id: '!xZbhusWZ:rc1.tunnel.dev.rocket.chat' as room.RoomID, - origin_server_ts: 1759760138291, - depth: 8, - prev_events: [ - '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', - ] as EventID[], - auth_events: [ - '$-C1Tf8UTaSZPEGcwVAUD1xGnKVS64HG_DxiEQVbIJBg', - '$N6KEZQ-ClhVa9P4_MgGmtnR32zZk-W-y7IebNjdoKqI', - '$gmoi4PDtLFJO_M4zHK0rGm-1zJePpApcfyNnXwSf5zM', - ] as EventID[], - origin: 'syn1.tunnel.dev.rocket.chat', - unsigned: { - age_ts: 1759760138291, - }, - hashes: { - sha256: 'hmapBX++nvDz12pTvdDRzt62kuAeyMqw5h0Mta3YH/I', - }, - signatures: { - 'syn1.tunnel.dev.rocket.chat': { - 'ed25519:a_FAET': - 'enVzK6E2K5gC11j4+G5Z+8aezriR2/2P2qqWI7/S8Qhs03ON3vkj9owszdN+bPNBklGQC5YMFCKQRf+TXp+eDw', + hashes: { + sha256: 'hmapBX++nvDz12pTvdDRzt62kuAeyMqw5h0Mta3YH/I', }, - }, - } satisfies room.Pdu; - await stateService.handlePdu(toEventBase(message)); - const stateAfterMessage = await stateService.getStateBeforeEvent( - toEventBase(message), - ); - const newPduIds = Array.from(stateAfterMessage.values()) - .map((e) => e.eventId) - .sort(); - expect(newPduIds).toStrictEqual(expectedAfterMessageSent.pdu_ids); - }); - - it('should handle concurrent joins fairly and build correct final state', async () => { - const users = []; - for (let i = 0; i < 20; i++) { - users.push(`@user${i}:example.com`); - } - - const { roomCreateEvent } = await createRoom('public'); - const roomId = roomCreateEvent.roomId; - const version = roomCreateEvent.getContent().room_version; - - await Promise.all(users.map((u) => joinUser(roomId, u))); - - const state = await stateService.getLatestRoomState2(roomId); - - for (const user of users) { - expect(state.isUserInRoom(user)).toBeTrue(); - } - - // all users should also be able to send a message - for (const user of users) { - const message = await stateService.buildEvent<'m.room.message'>( - { - type: 'm.room.message', - sender: user as room.UserID, - content: { msgtype: 'm.text', body: 'hello world' }, - room_id: roomId, - ...getDefaultFields(), + signatures: { + 'syn1.tunnel.dev.rocket.chat': { + 'ed25519:a_FAET': + 'enVzK6E2K5gC11j4+G5Z+8aezriR2/2P2qqWI7/S8Qhs03ON3vkj9owszdN+bPNBklGQC5YMFCKQRf+TXp+eDw', + }, }, - version, + } satisfies room.Pdu; + await stateService.handlePdu(toEventBase(message)); + const stateAfterMessage = await stateService.getStateBeforeEvent( + toEventBase(message), ); + const newPduIds = Array.from(stateAfterMessage.values()) + .map((e) => e.eventId) + .sort(); + expect(newPduIds).toStrictEqual(expectedAfterMessageSent.pdu_ids); + }); - await stateService.handlePdu(message); - - const event = await stateService.getEvent(message.eventId); + it('should handle concurrent joins fairly and build correct final state', async () => { + const users = []; + for (let i = 0; i < 20; i++) { + users.push(`@user${i}:example.com`); + } - expect(event?.isAuthRejected()).toBeFalse(); - } - }); + const { roomCreateEvent } = await createRoom('public'); + const roomId = roomCreateEvent.roomId; + const version = roomCreateEvent.getContent().room_version; - const label = (label: string, i: number) => { - return `[${i}] ${label}`; - }; + await Promise.all(users.map((u) => joinUser(roomId, u))); - for (let i = 1; i <= partialStateEvents.length; i++) { - describe(label('partial states', i), () => { - it(label('should not be able to complete the chain', i), async () => { - const { state } = await partialStateEvents[i - 1](); - const eventMap = new Map(); - const events = Object.values(state); + const state = await stateService.getLatestRoomState2(roomId); - for (const event of events) { - eventMap.set(event.eventId, event); - } + for (const user of users) { + expect(state.isUserInRoom(user)).toBeTrue(); + } - const hasNoPartial = events.every((event) => - event.getPreviousEventIds().every((prev) => eventMap.has(prev)), + // all users should also be able to send a message + for (const user of users) { + const message = await stateService.buildEvent<'m.room.message'>( + { + type: 'm.room.message', + sender: user as room.UserID, + content: { msgtype: 'm.text', body: 'hello world' }, + room_id: roomId, + ...getDefaultFields(), + }, + version, ); - expect(hasNoPartial).toBeFalse(); - }); + await stateService.handlePdu(message); - it(label('should be able to save partial states', i), async () => { - const { state, authChain } = await partialStateEvents[i - 1](); - const events = Object.values(state); + const event = await stateService.getEvent(message.eventId); - const stateId = await stateService.processInitialState( - events.map((e) => e.event), - authChain.map((e) => e.event), - ); + expect(event?.isAuthRejected()).toBeFalse(); + } + }); - console.log(state.ourUserJoinEvent.eventId); + const label = (label: string, i: number) => { + return `[${i}] ${label}`; + }; - expect(stateId).toBeString(); + for (let i = 1; i <= partialStateEvents.length; i++) { + describe(label('partial states', i), () => { + it(label('should not be able to complete the chain', i), async () => { + const { state } = await partialStateEvents[i - 1](); + const eventMap = new Map(); + const events = Object.values(state); - const event = await stateService.getEvent( - state.ourUserJoinEvent.eventId, - ); - expect(event?.isPartial()).toBeTrue(); - }); + for (const event of events) { + eventMap.set(event.eventId, event); + } - it(label( - 'should be able to save and detect partial states', - i, - ), async () => { - const { state, authChain } = await partialStateEvents[i - 1](); - const events = Object.values(state); + const hasNoPartial = events.every((event) => + event.getPreviousEventIds().every((prev) => eventMap.has(prev)), + ); - await stateService.processInitialState( - events.map((e) => e.event), - authChain.map((e) => e.event), - ); + expect(hasNoPartial).toBeFalse(); + }); - expect( - stateService.isRoomStatePartial(events[0].roomId), - ).resolves.toBeTrue(); - }); + it(label('should be able to save partial states', i), async () => { + const { state, authChain } = await partialStateEvents[i - 1](); + const events = Object.values(state); - it(label( - 'should complete the state as missing events get filled', - i, - ), async () => { - const { state, authChain, missingEvents } = - await partialStateEvents[i - 1](); + const stateId = await stateService.processInitialState( + events.map((e) => e.event), + authChain.map((e) => e.event), + ); - await stateService.processInitialState( - Object.values(state).map((e) => e.event), - authChain.map((e) => e.event), - ); + expect(stateId).toBeString(); - expect( - stateService.isRoomStatePartial(state.roomCreateEvent.roomId), - ).resolves.toBeTrue(); - - const eventStore = new Map(); - for (const e of (Object.values(state) as PersistentEventBase[]).concat( - missingEvents, - )) { - eventStore.set(e.eventId, e); - } + const event = await stateService.getEvent( + state.ourUserJoinEvent.eventId, + ); + expect(event?.isPartial()).toBeTrue(); + }); + + it(label( + 'should be able to save and detect partial states', + i, + ), async () => { + const { state, authChain } = await partialStateEvents[i - 1](); + const events = Object.values(state); + + await stateService.processInitialState( + events.map((e) => e.event), + authChain.map((e) => e.event), + ); - const eventsToWalk = await stateService.getPartialEvents( - state.creatorMembershipEvent.roomId, - ); + expect( + stateService.isRoomStatePartial(events[0].roomId), + ).resolves.toBeTrue(); + }); + + it(label( + 'should complete the state as missing events get filled', + i, + ), async () => { + const { state, authChain, missingEvents } = + await partialStateEvents[i - 1](); + + await stateService.processInitialState( + Object.values(state).map((e) => e.event), + authChain.map((e) => e.event), + ); - const store = stateService._getStore(state.roomCreateEvent.version); + expect( + stateService.isRoomStatePartial(state.roomCreateEvent.roomId), + ).resolves.toBeTrue(); - const remoteFetch = async (eventIds: EventID[]) => { - return eventIds.map((e) => eventStore.get(e)); - }; + const eventStore = new Map(); + for (const e of ( + Object.values(state) as PersistentEventBase[] + ).concat(missingEvents)) { + eventStore.set(e.eventId, e); + } - const walk = async (event: PersistentEventBase) => { - // for each previous event, walk - const previousEventsInStore = await store.getEvents( - event.getPreviousEventIds(), + const eventsToWalk = await stateService.getPartialEvents( + state.creatorMembershipEvent.roomId, ); - if ( - previousEventsInStore.length === event.getPreviousEventIds().length - ) { - console.log(`All previous events found in store ${event.eventId}`); - // start processing this event now - await stateService._resolveStateAtEvent(event); - return; - } - const eventIdsToFind = [] as EventID[]; - for (const previousEventId of event.getPreviousEventIds()) { + const store = stateService._getStore(state.roomCreateEvent.version); + + const remoteFetch = async (eventIds: EventID[]) => { + return eventIds.map((e) => eventStore.get(e)); + }; + + const walk = async (event: PersistentEventBase) => { + // for each previous event, walk + const previousEventsInStore = await store.getEvents( + event.getPreviousEventIds(), + ); if ( - !previousEventsInStore - .map((p) => p.eventId) - .includes(previousEventId) + previousEventsInStore.length === + event.getPreviousEventIds().length ) { - eventIdsToFind.push(previousEventId); + // start processing this event now + await stateService._resolveStateAtEvent(event); + return; } - } - - console.log(`Events to find ${eventIdsToFind}`); - const previousEvents = (await remoteFetch( - eventIdsToFind, - )) as PersistentEventBase[]; + const eventIdsToFind = [] as EventID[]; + for (const previousEventId of event.getPreviousEventIds()) { + if ( + !previousEventsInStore + .map((p) => p.eventId) + .includes(previousEventId) + ) { + eventIdsToFind.push(previousEventId); + } + } - expect(previousEvents.length).toBe( - event.getPreviousEventIds().length, - ); + const previousEvents = (await remoteFetch( + eventIdsToFind, + )) as PersistentEventBase[]; - previousEvents - .sort((e1, e2) => { - if (e1.depth !== e2.depth) { - return e1.depth - e2.depth; - } + expect(previousEvents.length).toBe( + event.getPreviousEventIds().length, + ); - if (e1.originServerTs !== e2.originServerTs) { - return e1.originServerTs - e2.originServerTs; - } + previousEvents + .sort((e1, e2) => { + if (e1.depth !== e2.depth) { + return e1.depth - e2.depth; + } - return e1.eventId.localeCompare(e2.eventId); - }) - .reverse(); + if (e1.originServerTs !== e2.originServerTs) { + return e1.originServerTs - e2.originServerTs; + } - for (const previousEvent of previousEvents) { - console.log(`Waling ${previousEvent.eventId}`); - await walk(previousEvent); - } + return e1.eventId.localeCompare(e2.eventId); + }) + .reverse(); - console.log( - `Finishing saving ${event.eventId}, all [${event.getPreviousEventIds().join(', ')}] events has been saved`, - ); + for (const previousEvent of previousEvents) { + await walk(previousEvent); + } - // once all previous events have been walked we process this event - await stateService._resolveStateAtEvent(event); - }; + // once all previous events have been walked we process this event + await stateService._resolveStateAtEvent(event); + }; - for (const event of eventsToWalk) { - console.log(`Starting walking ${event.eventId}`); - await walk(event).catch(console.error); - } + for (const event of eventsToWalk) { + await walk(event).catch(console.error); + } - // now room should state to not be in partial state - expect( - stateService.isRoomStatePartial(state.roomCreateEvent.roomId), - ).resolves.toBeFalse(); + // now room should state to not be in partial state + expect( + stateService.isRoomStatePartial(state.roomCreateEvent.roomId), + ).resolves.toBeFalse(); + }); }); - }); - } -}); + } + }), +); diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 82b40dbf0..50aa3df47 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -1,4 +1,5 @@ -import { createLogger, signEvent } from '@rocket.chat/federation-core'; +import { createLogger } from '@rocket.chat/federation-core'; +import { signJson } from '@rocket.chat/federation-crypto'; import { type EventID, type EventStore, @@ -101,6 +102,7 @@ export class StateService { return event.content; } + // TODO change type to RoomID async getRoomVersion(roomId: string): Promise { const createEvent = await this.eventRepository.findByRoomIdAndType( roomId, @@ -110,25 +112,11 @@ export class StateService { throw new UnknownRoomError(roomId as RoomID); } - return createEvent.event.content?.room_version as RoomVersion; - } - - // helps with logging state - private logState(label: string, state: State) { - const printableState = Array.from(state.entries()).map(([key, value]) => { - return { - internalStateKey: key, - strippedEvent: { - state_key: value.stateKey, - sender: value.sender, - origin: value.origin, - content: value.getContent(), - }, - }; - }); + if (createEvent.event.type !== 'm.room.create') { + throw new Error('Create event content malformed for room version'); + } - // TODO: change to debug later - this.logger.info({ state: printableState }, label); + return createEvent.event.content.room_version; } private async updateNextEventReferencesWithEvent(event: PersistentEventBase) { @@ -330,25 +318,21 @@ export class StateService { } public async signEvent(event: T) { - if (process.env.NODE_ENV === 'test') return event; - const signingKey = await this.configService.getSigningKey(); const origin = this.configService.serverName; - const result = await signEvent( + const { signatures: _, unsigned: __, ...toSign } = event.redactedEvent; + + const signature = await signJson( // Before signing the event, the content hash of the event is calculated as described below. The hash is encoded using Unpadded Base64 and stored in the event object, in a hashes object, under a sha256 key. // ^^ is done already through redactedEvent fgetter // The event object is then redacted, following the redaction algorithm. Finally it is signed as described in Signing JSON, using the server’s signing key (see also Retrieving server keys). - event.redactedEvent as any, - signingKey[0], - origin, - false, // already passed through redactedEvent, hash is already part of this + toSign, + signingKey, ); - const keyId = `${signingKey[0].algorithm}:${signingKey[0].version}`; - - event.addSignature(origin, keyId, result.signatures[origin][keyId]); + event.addSignature(origin, signingKey.id, signature); return event; } diff --git a/packages/homeserver/src/middlewares/isAuthenticated.ts b/packages/homeserver/src/middlewares/isAuthenticated.ts index 125912618..26d10e6d2 100644 --- a/packages/homeserver/src/middlewares/isAuthenticated.ts +++ b/packages/homeserver/src/middlewares/isAuthenticated.ts @@ -1,5 +1,9 @@ import { federationSDK } from '@rocket.chat/federation-sdk'; import Elysia from 'elysia'; +import { + FailedSignatureVerificationPreconditionError, + InvalidRequestSignatureError, +} from '../../../federation-sdk/src/services/signature-verification.service'; export const isAuthenticatedMiddleware = () => { return new Elysia({ @@ -18,42 +22,43 @@ export const isAuthenticatedMiddleware = () => { }; } - try { - let body: Record | undefined; - if (request.body) { - try { - const clone = request.clone(); - const text = await clone.text(); - body = text ? JSON.parse(text) : undefined; - } catch { - body = undefined; - } + let body: Record | undefined; + if (request.body) { + try { + const clone = request.clone(); + const text = await clone.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; } + } - const isValid = await federationSDK.verifyRequestSignature( + try { + await federationSDK.verifyRequestSignature( authorizationHeader, method, uri, body, ); - - if (!isValid) { + } catch (error) { + console.error('Signature verification error:', error); + if ( + error instanceof FailedSignatureVerificationPreconditionError || + error instanceof InvalidRequestSignatureError + ) { set.status = 401; - return { - authenticatedServer: undefined, - }; + } else { + set.status = 500; } - return { - authenticatedServer: isValid, - }; - } catch (error) { - console.error('Authentication error:', error); - set.status = 500; return { authenticatedServer: undefined, }; } + + return { + authenticatedServer: true, + }; }) .onBeforeHandle(({ authenticatedServer, set }) => { if (!authenticatedServer) { diff --git a/packages/room/src/manager/event-wrapper.ts b/packages/room/src/manager/event-wrapper.ts index 586d63128..2bd0e0dcb 100644 --- a/packages/room/src/manager/event-wrapper.ts +++ b/packages/room/src/manager/event-wrapper.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { + computeHashBuffer, encodeCanonicalJson, toUnpaddedBase64, } from '@rocket.chat/federation-crypto'; @@ -340,14 +341,8 @@ export abstract class PersistentEventBase< const { unsigned, signatures, ...toHash } = redactedEvent; // 2. The event is converted into Canonical JSON. - const canonicalJson = encodeCanonicalJson(toHash); // 3. A sha256 hash is calculated on the resulting JSON object. - const referenceHash = crypto - .createHash('sha256') - .update(canonicalJson) - .digest(); - - return referenceHash; + return computeHashBuffer(toHash); } // SPEC: https://spec.matrix.org/v1.12/server-server-api/#calculating-the-content-hash-for-an-event @@ -477,6 +472,10 @@ export abstract class PersistentEventBase< return this; } + getOriginKeys() { + return Object.keys(this.signatures[this.origin]); + } + toStrippedJson() { return encodeCanonicalJson({ eventId: this.eventId, @@ -487,4 +486,5 @@ export abstract class PersistentEventBase< }); } } + export type { EventStore }; diff --git a/packages/room/src/types/v3-11.ts b/packages/room/src/types/v3-11.ts index cfee2d9ac..b0676acad 100644 --- a/packages/room/src/types/v3-11.ts +++ b/packages/room/src/types/v3-11.ts @@ -88,7 +88,7 @@ export const PduMembershipTypeSchema = z.enum([ ]); export const PduMembershipEventContentSchema = z.object({ - avatar_url: z.string().url().optional(), + avatar_url: z.string().url().nullish(), displayname: z.string().optional(), is_direct: z .boolean()