From 9d645df31bf8cdd12a54d208832672eb22034039 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Wed, 21 May 2025 12:32:26 -0300 Subject: [PATCH 1/4] chore: improve typings --- .../services/federation-request.service.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/federation-sdk/src/services/federation-request.service.ts b/packages/federation-sdk/src/services/federation-request.service.ts index 418937679..fff47dc4e 100644 --- a/packages/federation-sdk/src/services/federation-request.service.ts +++ b/packages/federation-sdk/src/services/federation-request.service.ts @@ -4,14 +4,14 @@ import { Injectable, Logger } from '@nestjs/common'; import * as nacl from 'tweetnacl'; import { authorizationHeaders, computeAndMergeHash } from '../../../homeserver/src/authentication'; import { extractURIfromURL } from '../../../homeserver/src/helpers/url'; -import { signJson } from '../../../homeserver/src/signJson'; +import { EncryptionValidAlgorithm, signJson } from '../../../homeserver/src/signJson'; import { FederationConfigService } from './federation-config.service'; interface SignedRequest { method: string; domain: string; uri: string; - body?: any; + body?: Record; queryString?: string; } @@ -37,8 +37,8 @@ export class FederationRequestService { const privateKeyBytes = Buffer.from(signingKeyBase64, 'base64'); const keyPair = nacl.sign.keyPair.fromSecretKey(privateKeyBytes); - const signingKey = { - algorithm: 'ed25519', + const signingKey: SigningKey = { + algorithm: EncryptionValidAlgorithm.ed25519, version: signingKeyId.split(':')[1] || '1', privateKey: keyPair.secretKey, publicKey: keyPair.publicKey, @@ -57,27 +57,27 @@ export class FederationRequestService { this.logger.debug(`Making ${method} request to ${url.toString()}`); - let signedBody: unknown; + let signedBody: Record | undefined; if (body) { signedBody = await signJson( computeAndMergeHash({ ...body, signatures: {} }), - signingKey as any, + signingKey, serverName ); } const auth = await authorizationHeaders( serverName, - signingKey as unknown as SigningKey, + signingKey, domain, method, extractURIfromURL(url), - signedBody as any, + signedBody, ); const response = await fetch(url.toString(), { method, - ...(signedBody && { body: JSON.stringify(signedBody) }) as any, + ...(signedBody && { body: JSON.stringify(signedBody) }), headers: { Authorization: auth, ...discoveryHeaders, @@ -93,14 +93,14 @@ export class FederationRequestService { throw new Error(`Federation request failed: ${response.status} ${errorDetail}`); } - return response.json() as Promise; + return response.json(); } catch (error: any) { this.logger.error(`Federation request failed: ${error.message}`, error.stack); throw error; } } - async request(method: HttpMethod, targetServer: string, endpoint: string, body?: any, queryParams?: Record): Promise { + async request(method: HttpMethod, targetServer: string, endpoint: string, body?: Record, queryParams?: Record): Promise { let queryString = ''; if (queryParams) { From 569f6bab6ccd17883ac0f776dc6694baf8b8266c Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Wed, 21 May 2025 13:24:52 -0300 Subject: [PATCH 2/4] test: tests for federation request service --- .../federation-request.service.spec.ts | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 packages/federation-sdk/src/services/federation-request.service.spec.ts diff --git a/packages/federation-sdk/src/services/federation-request.service.spec.ts b/packages/federation-sdk/src/services/federation-request.service.spec.ts new file mode 100644 index 000000000..9dbc092ac --- /dev/null +++ b/packages/federation-sdk/src/services/federation-request.service.spec.ts @@ -0,0 +1,296 @@ +import { describe, it, beforeEach, afterEach, expect, spyOn, mock } from 'bun:test'; +import { FederationRequestService } from './federation-request.service'; +import { FederationConfigService } from './federation-config.service'; +import * as nacl from 'tweetnacl'; +import * as discovery from '@hs/homeserver/src/helpers/server-discovery/discovery'; +import * as authentication from '@hs/homeserver/src/authentication'; +import * as signJson from '@hs/homeserver/src/signJson'; +import * as url from '@hs/homeserver/src/helpers/url'; + +describe('FederationRequestService', () => { + let service: FederationRequestService; + let configService: FederationConfigService; + let originalFetch: typeof globalThis.fetch; + + 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 = { + address: 'target.example.com', + headers: { + 'Host': 'target.example.com', + 'X-Custom-Header': 'Test' + }, + }; + + 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(() => { + originalFetch = globalThis.fetch; + + spyOn(nacl.sign.keyPair, 'fromSecretKey').mockReturnValue(mockKeyPair); + spyOn(nacl.sign, 'detached').mockReturnValue(mockSignature); + + spyOn(discovery, 'resolveHostAddressByServerName').mockResolvedValue(mockDiscoveryResult); + spyOn(url, 'extractURIfromURL').mockReturnValue('/test/path?query=value'); + spyOn(authentication, 'authorizationHeaders').mockResolvedValue(mockAuthHeaders); + spyOn(signJson, 'signJson').mockResolvedValue(mockSignedJson); + spyOn(authentication, 'computeAndMergeHash').mockImplementation((obj: any) => obj); + + globalThis.fetch = Object.assign( + async (_url: string, _options?: RequestInit) => { + return { + ok: true, + status: 200, + json: async () => ({ result: 'success' }), + text: async () => '{"result":"success"}', + } as Response; + }, + { preconnect: () => { } } + ) as typeof fetch; + + configService = { + serverName: mockServerName, + signingKey: mockSigningKey, + signingKeyId: mockSigningKeyId, + } as FederationConfigService; + + service = new FederationRequestService(configService); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); + }); + + describe('makeSignedRequest', () => { + it('should make a successful signed request without body', async () => { + const fetchSpy = spyOn(globalThis, 'fetch'); + + const result = await service.makeSignedRequest({ + method: 'GET', + domain: 'target.example.com', + uri: '/test/path', + }); + + expect(configService.serverName).toBe(mockServerName); + expect(configService.signingKey).toBe(mockSigningKey); + expect(configService.signingKeyId).toBe(mockSigningKeyId); + + expect(nacl.sign.keyPair.fromSecretKey).toHaveBeenCalled(); + + expect(discovery.resolveHostAddressByServerName).toHaveBeenCalledWith( + 'target.example.com', + mockServerName + ); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://target.example.com/test/path', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: mockAuthHeaders, + 'X-Custom-Header': 'Test', + }), + }) + ); + + expect(result).toEqual({ result: 'success' }); + }); + + it('should make a successful signed request with body', async () => { + const fetchSpy = spyOn(globalThis, 'fetch'); + + const mockBody = { key: 'value' }; + + const result = await service.makeSignedRequest({ + method: 'POST', + domain: 'target.example.com', + uri: '/test/path', + body: mockBody, + }); + + expect(signJson.signJson).toHaveBeenCalledWith( + expect.objectContaining({ key: 'value', signatures: {} }), + expect.any(Object), + mockServerName + ); + + expect(authentication.authorizationHeaders).toHaveBeenCalledWith( + mockServerName, + expect.any(Object), + 'target.example.com', + 'POST', + '/test/path?query=value', + mockSignedJson + ); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://target.example.com/test/path', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockSignedJson), + }) + ); + + expect(result).toEqual({ result: 'success' }); + }); + + it('should make a signed request with query parameters', async () => { + const fetchSpy = spyOn(globalThis, 'fetch'); + + const result = await service.makeSignedRequest({ + method: 'GET', + domain: 'target.example.com', + uri: '/test/path', + queryString: 'param1=value1¶m2=value2', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://target.example.com/test/path?param1=value1¶m2=value2', + expect.any(Object) + ); + + expect(result).toEqual({ result: 'success' }); + }); + + it('should handle fetch errors properly', async () => { + globalThis.fetch = Object.assign( + async () => { + return { + ok: false, + status: 404, + text: async () => 'Not Found', + } 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"}', + } 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({ result: 'success' }); + + 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({ result: 'success' }); + + 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({ result: 'success' }); + + 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: '', + }); + }); + }); +}); From 73231248d961d1d9ba0aeb8a43e7b390a2483708 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Wed, 21 May 2025 13:58:32 -0300 Subject: [PATCH 3/4] chore: general type improvements --- .../src/services/federation.service.ts | 40 +++++++------ .../signature-verification.service.ts | 24 ++++++-- .../src/specs/federation-api.ts | 58 ++++++++++++++++--- packages/homeserver/src/signJson.ts | 2 +- 4 files changed, 91 insertions(+), 33 deletions(-) diff --git a/packages/federation-sdk/src/services/federation.service.ts b/packages/federation-sdk/src/services/federation.service.ts index e783eddbc..797fc2837 100644 --- a/packages/federation-sdk/src/services/federation.service.ts +++ b/packages/federation-sdk/src/services/federation.service.ts @@ -1,10 +1,11 @@ import type { EventBase } from '@hs/core/src/events/eventBase'; import { Injectable, Logger } from '@nestjs/common'; -import type { MakeJoinResponse, SendJoinResponse, SendTransactionResponse, Transaction } from '../specs/federation-api'; +import type { MakeJoinResponse, SendJoinResponse, SendTransactionResponse, Transaction, Version } from '../specs/federation-api'; import { FederationEndpoints } from '../specs/federation-api'; import { FederationConfigService } from './federation-config.service'; import { FederationRequestService } from './federation-request.service'; import { SignatureVerificationService } from './signature-verification.service'; +import type { ProtocolVersionKey } from '@hs/homeserver/src/signJson'; @Injectable() export class FederationService { @@ -14,7 +15,7 @@ export class FederationService { private readonly configService: FederationConfigService, private readonly requestService: FederationRequestService, private readonly signatureService: SignatureVerificationService, - ) {} + ) { } /** * Get a make_join template for a room and user @@ -28,7 +29,7 @@ export class FederationService { try { const uri = FederationEndpoints.makeJoin(roomId, userId); const queryParams: Record = {}; - + if (version) { queryParams.ver = version; } else { @@ -51,23 +52,23 @@ export class FederationService { domain: string, roomId: string, userId: string, - joinEvent: unknown, + joinEvent: MakeJoinResponse['event'], omitMembers = false, ): Promise { try { const eventWithOrigin = { - ...joinEvent as any, + ...joinEvent, origin: this.configService.serverName, origin_server_ts: Date.now(), }; - + const uri = FederationEndpoints.sendJoinV2(roomId, userId); const queryParams = omitMembers ? { 'omit_members': 'true' } : undefined; - + return await this.requestService.put( - domain, - uri, - eventWithOrigin, + domain, + uri, + eventWithOrigin, queryParams ); } catch (error: any) { @@ -86,7 +87,7 @@ export class FederationService { try { const txnId = `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; const uri = FederationEndpoints.sendTransaction(txnId); - + return await this.requestService.put(domain, uri, transaction); } catch (error: any) { this.logger.error(`sendTransaction failed: ${error?.message}`, error?.stack); @@ -104,7 +105,7 @@ export class FederationService { origin_server_ts: Date.now(), pdus: [event], }; - + return await this.sendTransaction(domain, transaction); } catch (error: any) { this.logger.error(`sendEvent failed: ${error?.message}`, error?.stack); @@ -132,7 +133,7 @@ export class FederationService { try { const uri = FederationEndpoints.getState(roomId); const queryParams = { 'event_id': eventId }; - + return await this.requestService.get(domain, uri, queryParams); } catch (error: any) { this.logger.error(`getState failed: ${error?.message}`, error?.stack); @@ -156,19 +157,24 @@ export class FederationService { /** * Get server version information */ - async getVersion(domain: string): Promise { + async getVersion(domain: string): Promise { try { - return await this.requestService.get(domain, FederationEndpoints.version); + return await this.requestService.get(domain, FederationEndpoints.version); } catch (error: any) { this.logger.error(`getVersion failed: ${error.message}`, error.stack); throw error; } } - + /** * Verify PDU from a remote server */ - async verifyPDU(event: any, originServer: string): Promise { + async verifyPDU< + T extends object & { + signatures?: Record>; + unsigned?: unknown; + }, + >(event: T, originServer: string): Promise { return this.signatureService.verifySignature(event, originServer); } } \ No newline at end of file diff --git a/packages/federation-sdk/src/services/signature-verification.service.ts b/packages/federation-sdk/src/services/signature-verification.service.ts index dea1f3887..772a7b154 100644 --- a/packages/federation-sdk/src/services/signature-verification.service.ts +++ b/packages/federation-sdk/src/services/signature-verification.service.ts @@ -1,3 +1,4 @@ +import type { ProtocolVersionKey } from '@hs/homeserver/src/signJson'; import { Injectable, Logger } from '@nestjs/common'; import * as nacl from 'tweetnacl'; @@ -24,8 +25,13 @@ export class SignatureVerificationService { /** * Verify a signature from a remote server */ - async verifySignature( - event: any, + async verifySignature< + T extends object & { + signatures?: Record>; + unsigned?: unknown; + }, +>( + event: T, originServer: string, getPublicKeyFn?: (origin: string, keyId: string) => Promise, ): Promise { @@ -36,8 +42,14 @@ export class SignatureVerificationService { } const signatureObj = event.signatures[originServer]; - const keyId = Object.keys(signatureObj)[0]; - const signature = signatureObj[keyId]; + + const entries = Object.entries(signatureObj); + if (entries.length === 0) { + this.logger.warn(`No signature keys found for ${originServer}`); + return false; + } + + const [keyId, signature] = entries[0]; if (!keyId || !signature) { this.logger.warn(`Invalid signature data for ${originServer}`); @@ -78,11 +90,11 @@ export class SignatureVerificationService { /** * Get public key from cache or fetch it from the server */ - private async getOrFetchPublicKey(serverName: string, keyId: string): Promise { + private async getOrFetchPublicKey(serverName: string, keyId: string): Promise { const cacheKey = `${serverName}:${keyId}`; if (this.cachedKeys.has(cacheKey)) { - return this.cachedKeys.get(cacheKey)!; + return this.cachedKeys.get(cacheKey); } try { diff --git a/packages/federation-sdk/src/specs/federation-api.ts b/packages/federation-sdk/src/specs/federation-api.ts index 3440669a4..b356f2c99 100644 --- a/packages/federation-sdk/src/specs/federation-api.ts +++ b/packages/federation-sdk/src/specs/federation-api.ts @@ -17,20 +17,20 @@ export const FederationEndpoints = { // Server discovery and authentication wellKnownServer: '/.well-known/matrix/server', keyServer: '/_matrix/key/v2/server', - + // Version information version: '/_matrix/federation/v1/version', - + // Querying room state and events getStateIds: (roomId: string) => `/_matrix/federation/v1/state_ids/${roomId}`, getState: (roomId: string) => `/_matrix/federation/v1/state/${roomId}`, getEvent: (eventId: string) => `/_matrix/federation/v1/event/${encodeURIComponent(eventId)}`, getEventAuth: (roomId: string, eventId: string) => `/_matrix/federation/v1/event_auth/${roomId}/${encodeURIComponent(eventId)}`, - + // Room backfill and missing events getMissingEvents: (roomId: string) => `/_matrix/federation/v1/get_missing_events/${roomId}`, backfill: (roomId: string) => `/_matrix/federation/v1/backfill/${roomId}`, - + // Joining/inviting and leaving rooms makeJoin: (roomId: string, userId: string) => `/_matrix/federation/v1/make_join/${roomId}/${userId}`, sendJoin: (roomId: string, eventId: string) => `/_matrix/federation/v1/send_join/${roomId}/${eventId}`, @@ -39,14 +39,14 @@ export const FederationEndpoints = { sendLeave: (roomId: string, eventId: string) => `/_matrix/federation/v1/send_leave/${roomId}/${eventId}`, invite: (roomId: string, eventId: string) => `/_matrix/federation/v1/invite/${roomId}/${eventId}`, inviteV2: (roomId: string, eventId: string) => `/_matrix/federation/v2/invite/${roomId}/${eventId}`, - + // Sending events sendTransaction: (txnId: string) => `/_matrix/federation/v1/send/${txnId}`, - + // User and profile data queryProfile: (userId: string) => '/_matrix/federation/v1/query/profile', userDevices: (userId: string) => `/_matrix/federation/v1/user/devices/${userId}`, - + // Public room directory publicRooms: '/_matrix/federation/v1/publicRooms' }; @@ -83,16 +83,54 @@ export const RoomVersionSchema = z.union([ z.literal('11') ]); +export const MakeJoinEventSchema = z.object({ + content: z.object({ + membership: z.literal('join'), + join_authorised_via_users_server: z.string().optional() + }), + origin: z.string(), + origin_server_ts: z.number(), + sender: z.string(), + state_key: z.string(), + type: z.literal('m.room.member') +}); + export const MakeJoinResponseSchema = z.object({ room_version: RoomVersionSchema, - event: z.record(z.unknown()) + event: MakeJoinEventSchema +}); + +export const SendJoinEventSchema = z.object({ + auth_events: z.array(z.string()), + content: z.object({ + membership: z.literal('join'), + join_authorised_via_users_server: z.string().optional() + }), + depth: z.number(), + hashes: z.object({ + sha256: z.string() + }), + origin: z.string(), + origin_server_ts: z.number(), + prev_events: z.array(z.string()), + room_id: RoomIdSchema, + sender: UserIdSchema, + signatures: z.record(z.string(), z.record(z.string(), z.string())), + state_key: z.string(), + type: z.literal('m.room.member'), + unsigned: z.object({ + age: z.number().optional() + }).optional() }); export const SendJoinResponseSchema = z.object({ state: z.array(z.any()), auth_chain: z.array(z.any()), event_id: EventIdSchema.optional(), - event: z.unknown() + event: SendJoinEventSchema.optional(), + origin: z.string().optional(), + members_omitted: z.boolean().optional(), + servers_in_room: z.array(z.string()).optional() }); export const TransactionSchema = z.object({ @@ -111,6 +149,8 @@ export const SendTransactionResponseSchema = z.object({ export type Version = z.infer; export type StateIds = z.infer; export type State = z.infer; +export type MakeJoinEventResponseSchema = z.infer; +export type SendJoinEventResponseSchema = z.infer; export type MakeJoinResponse = z.infer; export type SendJoinResponse = z.infer; export type Transaction = z.infer; diff --git a/packages/homeserver/src/signJson.ts b/packages/homeserver/src/signJson.ts index 2b714c737..0d6b8382d 100644 --- a/packages/homeserver/src/signJson.ts +++ b/packages/homeserver/src/signJson.ts @@ -7,7 +7,7 @@ export enum EncryptionValidAlgorithm { ed25519 = "ed25519", } -type ProtocolVersionKey = `${EncryptionValidAlgorithm}:${string}`; +export type ProtocolVersionKey = `${EncryptionValidAlgorithm}:${string}`; export type SignedEvent = T & { signatures: { From e731588d5f04fa9117421616c9f12ce2f5de56e1 Mon Sep 17 00:00:00 2001 From: Marcos Defendi Date: Wed, 21 May 2025 15:25:16 -0300 Subject: [PATCH 4/4] test: tests for signature verification --- .../signature-verification.service.spec.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/federation-sdk/src/services/signature-verification.service.spec.ts 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..041c68245 --- /dev/null +++ b/packages/federation-sdk/src/services/signature-verification.service.spec.ts @@ -0,0 +1,173 @@ +import { describe, it, beforeEach, afterEach, expect, spyOn, mock } from 'bun:test'; +import { SignatureVerificationService } from './signature-verification.service'; +import * as nacl from 'tweetnacl'; + +describe('SignatureVerificationService', () => { + let service: SignatureVerificationService; + let originalFetch: typeof globalThis.fetch; + + const mockOriginServer = 'example.org'; + const mockKeyId = 'ed25519:key1'; + const mockPublicKey = 'abc123publickey=='; + const mockSignature = 'xyz789signature=='; + + const mockEvent = { + type: 'test.event', + content: { message: 'Hello World' }, + room_id: '!roomid:example.org', + sender: '@user:example.org', + origin_server_ts: 1621543830000, + signatures: { + [mockOriginServer]: { + [mockKeyId]: mockSignature + } + } + }; + + const mockKeyData = { + server_name: mockOriginServer, + verify_keys: { + [mockKeyId]: { + key: mockPublicKey + } + } + }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + + service = new SignatureVerificationService(); + + globalThis.fetch = Object.assign( + async (_url: string, _options?: RequestInit) => { + return { + ok: true, + status: 200, + json: async () => mockKeyData + } as Response; + }, + { preconnect: () => { } } + ) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.restore(); + }); + + describe('verifySignature', () => { + it('should return false when no signatures exist', async () => { + const result = await service.verifySignature( + { content: 'test' }, + mockOriginServer + ); + expect(result).toBe(false); + }); + + it('should return false when no signature for the origin server exists', async () => { + const result = await service.verifySignature( + { signatures: { 'different.server': { [mockKeyId]: mockSignature } } }, + mockOriginServer + ); + expect(result).toBe(false); + }); + + it('should return false when no signature keys for the origin server exist', async () => { + const result = await service.verifySignature( + { signatures: { [mockOriginServer]: {} } }, + mockOriginServer + ); + expect(result).toBe(false); + }); + + it('should return false when fetching the public key fails', async () => { + globalThis.fetch = Object.assign( + async () => { + return { + ok: false, + status: 404 + } as Response; + }, + { preconnect: () => { } } + ) as typeof fetch; + + const result = await service.verifySignature(mockEvent, mockOriginServer); + expect(result).toBe(false); + }); + + it('should verify signature using provided public key function', async () => { + const verifyMock = spyOn(nacl.sign.detached, 'verify').mockReturnValue(true); + + const getPublicKey = async (_origin: string, _keyId: string) => mockPublicKey; + + const result = await service.verifySignature(mockEvent, mockOriginServer, getPublicKey); + + expect(result).toBe(true); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it('should verify signature by fetching the public key', async () => { + const verifyMock = spyOn(nacl.sign.detached, 'verify').mockReturnValue(true); + + const result = await service.verifySignature(mockEvent, mockOriginServer); + + expect(result).toBe(true); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it('should return false when signature verification fails', async () => { + const verifyMock = spyOn(nacl.sign.detached, 'verify').mockReturnValue(false); + + const result = await service.verifySignature(mockEvent, mockOriginServer); + + expect(result).toBe(false); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it('should exclude signatures and unsigned fields when verifying', async () => { + const verifyMock = spyOn(nacl.sign.detached, 'verify').mockImplementation( + (message, signature, publicKey) => { + const eventJson = JSON.parse(Buffer.from(message).toString()); + expect(eventJson.signatures).toBeUndefined(); + expect(eventJson.unsigned).toBeUndefined(); + return true; + } + ); + + const eventWithUnsigned = { + ...mockEvent, + unsigned: { age_ts: 12345 } + }; + + const result = await service.verifySignature(eventWithUnsigned, mockOriginServer); + + expect(result).toBe(true); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it('should return false when an error is thrown during verification', async () => { + spyOn(nacl.sign.detached, 'verify').mockImplementation(() => { + throw new Error('Mock error'); + }); + + const result = await service.verifySignature(mockEvent, mockOriginServer); + expect(result).toBe(false); + }); + + it('should use cached key data when available', async () => { + const fetchSpy = spyOn(globalThis, 'fetch'); + const verifyMock = spyOn(nacl.sign.detached, 'verify').mockReturnValue(true); + + await service.verifySignature(mockEvent, mockOriginServer); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + fetchSpy.mockReset(); + + const result = await service.verifySignature(mockEvent, mockOriginServer); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(0); + expect(verifyMock).toHaveBeenCalledTimes(2); + }); + }); +});