Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions packages/core/src/utils/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,24 @@ export const validateAuthorizationHeader = async <T extends object>(
destination,
...(content && { content }),
});

const signature = Uint8Array.from(atob(hash as string), (c) =>
c.charCodeAt(0),
);
const signingKeyBytes = Uint8Array.from(atob(signingKey as string), (c) =>
c.charCodeAt(0),
);
const messageBytes = new TextEncoder().encode(canonicalJson);
const isValid = nacl.sign.detached.verify(
messageBytes,
signature,
signingKeyBytes,
);
Comment on lines 89 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use Node-safe base64 decoding and support base64url (Matrix uses unpadded/base64url)

atob isn’t guaranteed in Node and doesn’t handle base64url. Normalize to base64 and decode with Buffer.

Apply:

-	const signature = Uint8Array.from(atob(hash as string), (c) =>
-		c.charCodeAt(0),
-	);
-	const signingKeyBytes = Uint8Array.from(atob(signingKey as string), (c) =>
-		c.charCodeAt(0),
-	);
-	const messageBytes = new TextEncoder().encode(canonicalJson);
-	const isValid = nacl.sign.detached.verify(
-		messageBytes,
-		signature,
-		signingKeyBytes,
-	);
+	const toB64 = (s: string) =>
+		s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
+	const signature = Uint8Array.from(Buffer.from(toB64(hash), 'base64'));
+	const signingKeyBytes = Uint8Array.from(Buffer.from(toB64(signingKey), 'base64'));
+	const messageBytes = new TextEncoder().encode(canonicalJson);
+	const isValid = nacl.sign.detached.verify(messageBytes, signature, signingKeyBytes);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const signature = Uint8Array.from(atob(hash as string), (c) =>
c.charCodeAt(0),
);
const signingKeyBytes = Uint8Array.from(atob(signingKey as string), (c) =>
c.charCodeAt(0),
);
const messageBytes = new TextEncoder().encode(canonicalJson);
const isValid = nacl.sign.detached.verify(
messageBytes,
signature,
signingKeyBytes,
);
const toB64 = (s: string) =>
s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
const signature = Uint8Array.from(Buffer.from(toB64(hash), 'base64'));
const signingKeyBytes = Uint8Array.from(Buffer.from(toB64(signingKey), 'base64'));
const messageBytes = new TextEncoder().encode(canonicalJson);
const isValid = nacl.sign.detached.verify(messageBytes, signature, signingKeyBytes);
🤖 Prompt for AI Agents
In packages/core/src/utils/authentication.ts around lines 89 to 100, the code
uses atob for base64 decoding which is not Node-safe and does not support
base64url; normalize the incoming hash and signingKey strings to standard base64
by replacing '-' with '+' and '_' with '/' and padding with '=' to a multiple of
4, then decode using Buffer.from(..., 'base64') and convert the resulting Buffer
to Uint8Array for signature and signing key bytes before calling
nacl.sign.detached.verify; update both decodes (signature and signingKey) this
way so Node and unpadded base64url inputs are handled correctly.


if (
!nacl.sign.detached.verify(
new TextEncoder().encode(canonicalJson),
signature,
Uint8Array.from(atob(signingKey as string), (c) => c.charCodeAt(0)),
)
) {
throw new Error(`Invalid signature for ${destination}`);
if (!isValid) {
throw new Error(
`Invalid signature from ${origin} for request to ${destination}`,
);
}

return true;
Expand Down
145 changes: 120 additions & 25 deletions packages/core/src/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,90 @@ import https from 'node:https';

type RequestOptions = Parameters<typeof https.request>[1];

export type MultipartResult = {
content: Buffer;
headers?: Record<string, string>;
redirect?: string;
};

/**
* parses Matrix federation multipart/mixed media responses according to spec.
* https://spec.matrix.org/v1.15/server-server-api/#get_matrixfederationv1mediadownloadmediaid
*/
function parseMultipart(buffer: Buffer, boundary: string): MultipartResult {
const bufferStr = buffer.toString();

// check if the second part contains a Location header (CDN redirect)
// pattern: after first boundary and JSON part, look for Location header
const parts = bufferStr.split(`--${boundary}`);
if (parts.length >= 3) {
const secondPart = parts[2];
const locationMatch = secondPart.match(/\r?\nLocation:\s*(.+)\r?\n/i);

if (locationMatch) {
return {
content: Buffer.from(''),
redirect: locationMatch[1].trim(),
};
}
}

// find where the last part's content starts (after the last \r\n\r\n)
const lastHeaderEnd = buffer.lastIndexOf('\r\n\r\n');
if (lastHeaderEnd === -1) return { content: buffer };

const binaryStart = lastHeaderEnd + 4;
const closingBoundary = buffer.lastIndexOf(`\r\n--${boundary}`);

const content =
closingBoundary > binaryStart
? buffer.subarray(binaryStart, closingBoundary)
: buffer.subarray(binaryStart);

return { content };
}

function handleJson<T>(contentType: string, body: Buffer): Promise<T | null> {
if (!contentType.includes('application/json')) {
return Promise.resolve(null);
}

try {
return Promise.resolve(JSON.parse(body.toString()));
} catch {
return Promise.resolve(null);
}
}

function handleText(contentType: string, body: Buffer): Promise<string> {
if (!contentType.includes('text/')) {
return Promise.resolve('');
}

return Promise.resolve(body.toString());
}

function handleMultipart(
contentType: string,
body: Buffer,
): Promise<MultipartResult | null> {
if (!contentType.includes('multipart')) {
return Promise.resolve(null);
}

// extract boundary from content-type header
const boundaryMatch = contentType.match(/boundary=([^;,\s]+)/);
if (!boundaryMatch) {
return Promise.resolve(null);
}

// remove quotes if present
const boundary = boundaryMatch[1].replace(/^["']|["']$/g, '');
return Promise.resolve(parseMultipart(body, boundary));
}

// this fetch is used when connecting to a multihome server, same server hosting multiple homeservers, and we need to verify the cert with the right SNI (hostname), or else, cert check will fail due to connecting through ip and not hostname (due to matrix spec).
export async function fetch(url: URL, options: RequestInit) {
export async function fetch<T>(url: URL, options: RequestInit) {
const serverName = new URL(
`http://${(options.headers as OutgoingHttpHeaders).Host}` as string,
).hostname;
Expand All @@ -14,49 +96,62 @@ export async function fetch(url: URL, options: RequestInit) {
port: url.port,
method: options.method,
path: url.pathname + url.search,
headers: {
...(options.headers as OutgoingHttpHeaders),
'content-type': 'application/json',
},
headers: options.headers as OutgoingHttpHeaders,
servername: serverName,
};

try {
const response: { statusCode: number | undefined; body: string } =
await new Promise((resolve, reject) => {
const request = https.request(requestParams, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: data,
});
});
const response: {
statusCode: number | undefined;
body: Buffer;
headers: OutgoingHttpHeaders;
} = await new Promise((resolve, reject) => {
const request = https.request(requestParams, (res) => {
const chunks: Buffer[] = [];

res.on('data', (chunk) => {
chunks.push(chunk);
});
request.on('error', (err) => {
reject(err);

res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: Buffer.concat(chunks),
headers: res.headers,
});
});
});

request.end(options.body);
request.on('error', (err) => {
reject(err);
});

request.end(options.body);
});

const contentType = response.headers['content-type'] || '';

return {
ok: response.statusCode
? response.statusCode >= 200 && response.statusCode < 300
: false,
json: () => JSON.parse(response.body),
text: () => response.body,
buffer: () => Promise.resolve(response.body),
json: () => handleJson<T>(contentType, response.body),
text: () => handleText(contentType, response.body),
multipart: () => handleMultipart(contentType, response.body),
status: response.statusCode,
headers: response.headers,
};
} catch (err) {
return {
ok: false,
json: () => undefined,
text: () => (err instanceof Error ? err.message : String(err)),
status: undefined,
headers: {},
buffer: () => Promise.resolve(Buffer.from('')),
json: () => Promise.resolve(null),
text: () =>
Promise.resolve(err instanceof Error ? err.message : String(err)),
multipart: () => Promise.resolve(null),
};
}
}
20 changes: 20 additions & 0 deletions packages/federation-sdk/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import { MissingEventsQueue } from './queues/missing-event.queue';
import { StagingAreaQueue } from './queues/staging-area.queue';
import { EventRepository } from './repositories/event.repository';
import { Key, KeyRepository } from './repositories/key.repository';
import {
MatrixBridgedRoom,
MatrixBridgedRoomRepository,
} from './repositories/matrix-bridged-room.repository';
import { Room, RoomRepository } from './repositories/room.repository';
import { Server, ServerRepository } from './repositories/server.repository';
import { StateRepository, StateStore } from './repositories/state.repository';
import { Upload, UploadRepository } from './repositories/upload.repository';
import { ConfigService } from './services/config.service';
import { DatabaseConnectionService } from './services/database-connection.service';
import { EduService } from './services/edu.service';
Expand Down Expand Up @@ -81,11 +86,26 @@ export async function createFederationContainer(
useValue: db.collection<Server>('servers'),
});

container.register<Collection<Upload>>('UploadCollection', {
useValue: db.collection<Upload>('rocketchat_uploads'),
});

container.register<Collection<MatrixBridgedRoom>>(
'MatrixBridgedRoomCollection',
{
useValue: db.collection<MatrixBridgedRoom>(
'rocketchat_matrix_bridged_rooms',
),
},
);

container.registerSingleton(EventRepository);
container.registerSingleton(KeyRepository);
container.registerSingleton(RoomRepository);
container.registerSingleton(StateRepository);
container.registerSingleton(ServerRepository);
container.registerSingleton(MatrixBridgedRoomRepository);
container.registerSingleton(UploadRepository);

container.registerSingleton(FederationRequestService);
container.registerSingleton(SignatureVerificationService);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Collection } from 'mongodb';
import { inject, singleton } from 'tsyringe';

export type MatrixBridgedRoom = {
rid: string; // Rocket.Chat room ID
mri: string; // Matrix room ID
fromServer: string;
};

@singleton()
export class MatrixBridgedRoomRepository {
constructor(
@inject('MatrixBridgedRoomCollection')
private readonly collection: Collection<MatrixBridgedRoom>,
) {}

async findMatrixRoomId(rocketChatRoomId: string): Promise<string | null> {
const bridgedRoom = await this.collection.findOne({
rid: rocketChatRoomId,
});

return bridgedRoom?.mri || null;
}
}
26 changes: 26 additions & 0 deletions packages/federation-sdk/src/repositories/upload.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Collection } from 'mongodb';
import { inject, singleton } from 'tsyringe';

export type Upload = {
rid: string;
federation: {
mxcUri: string;
serverName: string;
mediaId: string;
};
};

@singleton()
export class UploadRepository {
constructor(
@inject('UploadCollection') private readonly collection: Collection<Upload>,
) {}

async findRocketChatRoomIdByMediaId(mediaId: string): Promise<string | null> {
const upload = await this.collection.findOne({
'federation.mediaId': mediaId,
});

return upload?.rid || null;
}
}
Loading
Loading