diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 472ab9b22..9fcce1eb3 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -77,6 +77,8 @@ jobs: - build-synapse - build-homerunner env: + GITHUB_ACTIONS: true + IRC_TEST_SECURE: true IRCBRIDGE_TEST_PGDB: "ircbridge_integtest" IRCBRIDGE_TEST_PGURL: "postgresql://postgres_user:postgres_password@localhost" IRCBRIDGE_TEST_ENABLEPG: "yes" @@ -103,6 +105,7 @@ jobs: image: ghcr.io/ergochat/ergo:stable ports: - 6667:6667 + - 6697:6697 steps: - name: Install Complement Dependencies run: | @@ -153,6 +156,8 @@ jobs: - build-synapse - build-homerunner env: + GITHUB_ACTIONS: true + IRC_TEST_SECURE: true IRCBRIDGE_TEST_PGDB: "ircbridge_integtest" IRCBRIDGE_TEST_PGURL: "postgresql://postgres_user:postgres_password@localhost" IRCBRIDGE_TEST_ENABLEPG: "yes" @@ -180,6 +185,7 @@ jobs: image: ghcr.io/ergochat/ergo:stable ports: - 6667:6667 + - 6697:6697 steps: - name: Install Complement Dependencies run: | diff --git a/changelog.d/1757.feature b/changelog.d/1757.feature new file mode 100644 index 000000000..f9e2457eb --- /dev/null +++ b/changelog.d/1757.feature @@ -0,0 +1 @@ +Add support for authenticating via CertFP ([RFC4422](https://datatracker.ietf.org/doc/html/rfc4422#appendix-A)). \ No newline at end of file diff --git a/docs/admin_room.md b/docs/admin_room.md index e4963137c..183341e95 100644 --- a/docs/admin_room.md +++ b/docs/admin_room.md @@ -58,7 +58,27 @@ before you can authenticate. To authenticate with your new settings, use [`!reconnect`](#reconnect). +### `!certfp` +`!certfp [irc.example.net]` + +Store a certificate / private key pair for use with SASL / authenticating with the server. You will be prompted to enter a certificate +after entering this command. [libera.chat have a useful guide on how to set this up](https://libera.chat/guides/certfp) + +**This action will store your private key in encrypted form on the IRC bridge**, so be sure to use a unique cert for the IRC service. + +When prompted, enter your certicate and private key in one block, e.g: + +``` +-----BEGIN CERTIFICATE----- +...your cert +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +...your key +-----END PRIVATE KEY----- +``` + +To authenticate with your new settings, use [`!reconnect`](#reconnect). ### `!reconnect` `!reconnect [irc.example.net]` diff --git a/package.json b/package.json index 561a82ab8..57b072d86 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,10 @@ "logform": "^2.4.2", "matrix-appservice-bridge": "^9.0.1", "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.6.6-element.1", - "matrix-org-irc": "^2.1.0", + "matrix-org-irc": "^2.2.0", "nopt": "^6.0.0", "p-queue": "^6.6.2", - "pg": "^8.8.0", + "pg": "^8.11.0", "quick-lru": "^5.1.1", "sanitize-html": "^2.7.2", "semver": "^7.5.4", diff --git a/spec/e2e/authentication.spec.ts b/spec/e2e/authentication.spec.ts new file mode 100644 index 000000000..b9dc3703d --- /dev/null +++ b/spec/e2e/authentication.spec.ts @@ -0,0 +1,101 @@ +import { TestIrcServer } from "matrix-org-irc"; +import { IrcBridgeE2ETest } from "../util/e2e-test"; +import { describe, it } from "@jest/globals"; +import { delay } from "../../src/promiseutil"; +import { exec } from "node:child_process"; +import { getKeyPairFromString } from "../../src/bridge/AdminRoomHandler"; +import { randomUUID } from "node:crypto"; + +async function generateCertificatePair() { + return new Promise>((resolve, reject) => { + exec( + 'openssl req -nodes -newkey rsa:2048 -keyout - -x509 -days 3 -out -' + + ' -subj "/C=US/ST=Utah/L=Lehi/O=Your Company, Inc./OU=IT/CN=yourdomain.com"', { + timeout: 5000, + }, + (err, stdout) => { + if (err) { + reject(err); + return; + } + resolve(getKeyPairFromString(stdout)); + }); + }) +} + + +async function expectMsg(msgSet: string[], expected: string, timeoutMs = 5000) { + let waitTime = 0; + do { + waitTime += 200; + await delay(200); + if (waitTime > timeoutMs) { + throw Error(`Timeout waiting for "${expected}, instead got\n\t${msgSet.join('\n\t')}"`); + } + } while (!msgSet.includes(expected)) +} + +const PASSWORD = randomUUID(); + +/** + * Note, this test assumes the IRCD we're testing against has services enabled + * and certfp support. This isn't terribly standard, but we test with ergo which + * has all this supported. + */ +describe('Authentication tests', () => { + let testEnv: IrcBridgeE2ETest; + let certPair: ReturnType; + beforeEach(async () => { + certPair = await generateCertificatePair(); + testEnv = await IrcBridgeE2ETest.createTestEnv({ + matrixLocalparts: [TestIrcServer.generateUniqueNick("alice")], + ircNicks: ["bob_authtest"], + traceToFile: true, + }); + await testEnv.setUp(); + }); + afterEach(() => { + return testEnv?.tearDown(); + }); + it('should be able to add a client certificate with the !certfp command', async () => { + const { homeserver, ircBridge } = testEnv + const alice = homeserver.users[0].client; + const { bob_authtest: bob } = testEnv.ircTest.clients; + const nickServMsgs: string[] = []; + const adminRoomPromise = await testEnv.createAdminRoomHelper(alice); + const channel = TestIrcServer.generateUniqueChannel('authtest'); + bob.on('notice', (from, _to, notice) => { + if (from === 'NickServ') { + nickServMsgs.push(notice); + } + }); + await bob.say('NickServ', `REGISTER ${PASSWORD}}`); + await expectMsg(nickServMsgs, 'Account created'); + await expectMsg(nickServMsgs, `You're now logged in as ${bob.nick}`); + bob.say('NickServ', `CERT ADD ${certPair.cert.fingerprint256}`); + await expectMsg(nickServMsgs, 'Certificate fingerprint successfully added'); + + const adminRoomId = adminRoomPromise; + const responseOne = alice.waitForRoomEvent({ eventType: 'm.room.message', sender: ircBridge.appServiceUserId }); + await alice.sendText(adminRoomId, '!certfp'); + expect((await responseOne).data.content.body).toEqual( + "Please enter your certificate and private key (without formatting) for localhost. Say 'cancel' to cancel." + ); + const responseTwo = alice.waitForRoomEvent({ eventType: 'm.room.message', sender: ircBridge.appServiceUserId }); + await alice.sendText(adminRoomId, + certPair.cert.toString()+"\n"+certPair.privateKey.export({type: "pkcs8", format: "pem"}) + ); + expect((await responseTwo).data.content.body).toEqual( + 'Successfully stored certificate for localhost. Use !reconnect to use this cert.' + ); + + await testEnv.joinChannelHelper(alice, adminRoomId, channel); + await alice.waitForRoomEvent({ + eventType: 'm.room.message', + roomId: adminRoomId, + sender: ircBridge.appServiceUserId, + body: `SASL authentication successful: You are now logged in as ${bob.nick}` + }); + + }); +}); diff --git a/spec/e2e/jest.config.js b/spec/e2e/jest.config.js index 52fa6851a..e88327750 100644 --- a/spec/e2e/jest.config.js +++ b/spec/e2e/jest.config.js @@ -1,9 +1,11 @@ +const isGithubActions = process.env.GITHUB_ACTIONS === 'true'; /** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'node', transformIgnorePatterns: ['/node_modules/'], testTimeout: 60000, + reporters: isGithubActions ? [['github-actions', {silent: false}], 'summary'] : ['default'], transform: { // Use the root tsconfig.json // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/tsconfig/#examples diff --git a/spec/e2e/powerlevels.spec.ts b/spec/e2e/powerlevels.spec.ts index aa82fc253..9263c16ad 100644 --- a/spec/e2e/powerlevels.spec.ts +++ b/spec/e2e/powerlevels.spec.ts @@ -18,7 +18,7 @@ describe('Ensure powerlevels are appropriately applied', () => { return testEnv?.tearDown(); }); it('should update powerlevel of IRC user when OPed by an IRC user', async () => { - const channel = `#${TestIrcServer.generateUniqueNick("test")}`; + const channel = TestIrcServer.generateUniqueChannel("test"); const { homeserver } = testEnv; const alice = homeserver.users[0].client; const { bob, charlie } = testEnv.ircTest.clients; @@ -28,11 +28,15 @@ describe('Ensure powerlevels are appropriately applied', () => { // Create the channel await bob.join(channel); + const aliceJoinPromise = bob.waitForEvent('join'); const cRoomId = await testEnv.joinChannelHelper(alice, await testEnv.createAdminRoomHelper(alice), channel); + await aliceJoinPromise; // Now have charlie join and be opped. const charlieJoinPromise = bob.waitForEvent('join'); await charlie.join(channel); + await charlieJoinPromise; + const operatorPL = testEnv.ircBridge.config.ircService.servers.localhost.modePowerMap!.o; const plEvent = alice.waitForPowerLevel( cRoomId, { @@ -44,7 +48,7 @@ describe('Ensure powerlevels are appropriately applied', () => { }, ); - await charlieJoinPromise; + await bob.send('MODE', channel, '+o', charlie.nick); expect(await plEvent).toBeDefined(); }); diff --git a/spec/unit/StringCrypto.spec.ts b/spec/unit/StringCrypto.spec.ts new file mode 100644 index 000000000..fa219dda2 --- /dev/null +++ b/spec/unit/StringCrypto.spec.ts @@ -0,0 +1,41 @@ +import { createPrivateKey, generateKeyPairSync, randomBytes } from 'node:crypto'; +import { StringCrypto } from '../../src/datastore/StringCrypto'; + +describe('StringCrypto', () => { + let privateKey; + beforeEach(() => { + privateKey = createPrivateKey(generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + } + }).privateKey); + }); + it('can encrypt a string', () => { + const str = new StringCrypto(privateKey).encrypt('This is a string to encrypt'); + expect(Buffer.from(str, 'base64').length).toEqual(256) + }); + it('can decrypt a string', () => { + const crypto = new StringCrypto(privateKey); + const originalText = 'This is another string'; + const encrypedString = crypto.encrypt(originalText); + expect(crypto.decrypt(encrypedString)).toEqual(originalText); + }); + it('can encrypt a large string', async () => { + const crypto = new StringCrypto(privateKey); + const originalText = randomBytes(8192).toString('base64'); + const encrypedString = await crypto.encryptLargeString(originalText); + expect(encrypedString.length).toEqual(14920); + }); + it('can decrypt a large string', async () => { + const originalText = randomBytes(8192).toString('base64'); + const crypto = new StringCrypto(privateKey); + const encrypedString = await crypto.encryptLargeString(originalText); + expect(await crypto.decryptLargeString(encrypedString)).toEqual(originalText); + }); +}); diff --git a/spec/unit/pool-service/IrcClientRedisState.ts b/spec/unit/pool-service/IrcClientRedisState.ts index e6e4d14e7..c0fcb1f41 100644 --- a/spec/unit/pool-service/IrcClientRedisState.ts +++ b/spec/unit/pool-service/IrcClientRedisState.ts @@ -72,7 +72,6 @@ describe("IrcClientRedisState", () => { expect(state.loggedIn).toBeTrue(); expect(state.registered).toBeTrue(); expect(state.chans.size).toBe(1); - console.log(state); }); it('should be able to repair previously buggy state', async () => { const existingState = { diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts index a94fcbff5..c71eca102 100644 --- a/spec/util/e2e-test.ts +++ b/spec/util/e2e-test.ts @@ -18,7 +18,8 @@ dns.setDefaultResultOrder('ipv4first'); const WAIT_EVENT_TIMEOUT = 10000; -const DEFAULT_PORT = parseInt(process.env.IRC_TEST_PORT ?? '6667', 10); +const IRC_SECURE = process.env.IRC_TEST_SECURE === "true"; +const DEFAULT_PORT = parseInt(process.env.IRC_TEST_PORT ?? IRC_SECURE ? "6697" : "6667", 10); const DEFAULT_ADDRESS = process.env.IRC_TEST_ADDRESS ?? "127.0.0.1"; const IRCBRIDGE_TEST_REDIS_URL = process.env.IRCBRIDGE_TEST_REDIS_URL; @@ -74,7 +75,7 @@ export class E2ETestMatrixClient extends MatrixClient { } public async waitForRoomEvent>( - opts: {eventType: string, sender: string, roomId?: string, stateKey?: string} + opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string} ): Promise<{roomId: string, data: { sender: string, type: string, state_key?: string, content: T, event_id: string, }}> { @@ -95,6 +96,9 @@ export class E2ETestMatrixClient extends MatrixClient { return undefined; } const body = 'body' in eventData.content && eventData.content.body; + if (opts.body && body !== opts.body) { + return undefined; + } console.info( // eslint-disable-next-line max-len `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}` @@ -182,7 +186,11 @@ export class IrcBridgeE2ETest { const workerID = parseInt(process.env.JEST_WORKER_ID ?? '0'); const { matrixLocalparts, config } = opts; - const ircTest = new TestIrcServer(); + const ircTest = new TestIrcServer(undefined, undefined, { + secure: IRC_SECURE, + port: DEFAULT_PORT, + selfSigned: true, + }); const [postgresDb, homeserver] = await Promise.all([ this.createDatabase(), createHS(["ircbridge_bot", ...matrixLocalparts || []], workerID), @@ -241,6 +249,9 @@ export class IrcBridgeE2ETest { port: DEFAULT_PORT, additionalAddresses: [DEFAULT_ADDRESS], onlyAdditionalAddresses: true, + sasl: true, + ssl: IRC_SECURE, + sslselfsign: IRC_SECURE, matrixClients: { userTemplate: "@irc_$NICK", displayName: "$NICK", @@ -290,7 +301,8 @@ export class IrcBridgeE2ETest { debugApi: { enabled: false, port: 0, - } + }, + passwordEncryptionKeyPath: './spec/support/passkey.pem', }, ...config, ...(redisUri && { connectionPool: { @@ -322,7 +334,17 @@ export class IrcBridgeE2ETest { traceLog.write( `${Date.now() - startTime}ms [IRC:${clientId}] ${JSON.stringify(msg)} \n` ); - }) + }); + client.on('connect', () => { + traceLog.write( + `${Date.now() - startTime}ms [IRC:${clientId}] connected \n` + ); + }); + client.on('error', (err) => { + traceLog.write( + `${Date.now() - startTime}ms [IRC:${clientId}] error ${err} \n` + ); + }); } for (const {client, userId} of Object.values(homeserver.users)) { client.on('room.event', (roomId, eventData) => { diff --git a/src/bridge/AdminRoomHandler.ts b/src/bridge/AdminRoomHandler.ts index ad25697b4..43decb92d 100644 --- a/src/bridge/AdminRoomHandler.ts +++ b/src/bridge/AdminRoomHandler.ts @@ -28,6 +28,7 @@ import { getBridgeVersion } from "matrix-appservice-bridge"; import { Provisioner } from "../provisioning/Provisioner"; import { IrcProvisioningError } from "../provisioning/Schema"; import { validateChannelName } from "../models/IrcRoom"; +import { X509Certificate, createPrivateKey } from "node:crypto"; const log = logging("AdminRoomHandler"); @@ -61,6 +62,33 @@ export function parseCommandFromEvent(event: { content?: { body?: unknown }}, pr return { cmd, args }; } + + +export function getKeyPairFromString(keypairString: string) { + const keyStart = keypairString.indexOf('-----BEGIN PRIVATE KEY-----'); + const keyEnd = keypairString.indexOf('-----END PRIVATE KEY-----'); + const certStart = keypairString.indexOf('-----BEGIN CERTIFICATE-----'); + const certEnd = keypairString.indexOf('-----END CERTIFICATE-----'); + if (certStart === -1) { + throw Error('Missing BEGIN CERTIFICATE'); + } + if (certEnd === -1) { + throw Error('Missing END CERTIFICATE'); + } + if (keyStart === -1) { + throw Error('Missing BEGIN PRIVATE KEY'); + } + if (keyEnd === -1) { + throw Error('Missing END PRIVATE KEY'); + } + const certStr = keypairString.slice(certStart, certEnd + '-----END CERTIFICATE-----'.length); + const privateKeyStr = keypairString.slice(keyStart, keyEnd + '-----END CERTIFICATE-----'.length); + const privateKey = createPrivateKey(privateKeyStr); + const cert = new X509Certificate(certStr); + return { cert, privateKey }; + +} + // This is just a length to avoid silly long usernames const SANE_USERNAME_LENGTH = 64; @@ -75,6 +103,8 @@ const SANE_USERNAME_LENGTH = 64; // (0x00 to 0x1F, plus DEL (0x7F)), as they are most likely mistakes. const SASL_USERNAME_INVALID_CHARS_PATTERN = /[\x00-\x20\x7F]+/; // eslint-disable-line +const CERT_FP_TIMEOUT_MS = 90 * 1000; + interface Command { example: string; summary: string; @@ -87,6 +117,10 @@ interface Heading { const COMMANDS: {[command: string]: Command|Heading} = { 'Actions': { heading: true }, + "certfp": { + example: `!certfp [irc.example.net]`, + summary: `Store a certificate for authenticating with the remote network`, + }, "cmd": { example: `!cmd [irc.example.net] COMMAND [arg0 [arg1 [...]]]`, summary: "Issue a raw IRC command. These will not produce a reply." + @@ -166,19 +200,28 @@ class ServerRequiredError extends Error { const USER_FEATURES = ["mentions"]; export class AdminRoomHandler { private readonly botUser: MatrixUser; + private readonly roomIdsExpectingCertFp: Map void> = new Map(); constructor(private ircBridge: IrcBridge, private matrixHandler: MatrixHandler) { this.botUser = new MatrixUser(ircBridge.appServiceUserId, undefined, false); } public async onAdminMessage(req: BridgeRequest, event: MatrixSimpleMessage, adminRoom: MatrixRoom) { req.log.info("Handling admin command from %s", event.sender); + + const certFpFunction = this.roomIdsExpectingCertFp.get(adminRoom.roomId); + if (certFpFunction) { + this.roomIdsExpectingCertFp.delete(adminRoom.roomId); + certFpFunction(event.content.body); + return; + } + const parseResult = parseCommandFromEvent(event); let response: MatrixAction|void; if (parseResult) { const { cmd, args } = parseResult; try { - response = await this.handleCommand(cmd, args, req, event); + response = await this.handleCommand(cmd, args, req, event, adminRoom); } catch (err) { if (err instanceof ServerRequiredError) { @@ -203,7 +246,9 @@ export class AdminRoomHandler { } } - private async handleCommand(cmd: string, args: string[], req: BridgeRequest, event: MatrixSimpleMessage) { + private async handleCommand( + cmd: string, args: string[], req: BridgeRequest, event: MatrixSimpleMessage, adminRoom: MatrixRoom + ) { const userPermission = this.getUserPermission(event.sender); const requiredPermission = (COMMANDS[cmd] as Command|undefined)?.requiresPermission; if (requiredPermission && requiredPermission > userPermission) { @@ -216,6 +261,10 @@ export class AdminRoomHandler { return new MatrixAction(ActionType.Notice, "You have been marked as active by the bridge"); case "join": return await this.handleJoin(req, args, event.sender); + case "certfp": + return await this.handleCertfp(req, args, event.sender, adminRoom); + case "removecertfp": + return await this.handleRemoveCertfp(args, event.sender); case "cmd": return await this.handleCmd(req, args, event.sender); case "whois": @@ -252,6 +301,145 @@ export class AdminRoomHandler { } } + private async getOrCreateClientConfig(userId: string, server: IrcServer) { + const store = this.ircBridge.getStore(); + const config = await store.getIrcClientConfig(userId, server.domain); + if (config) { + return config; + } + return IrcClientConfig.newConfig( + new MatrixUser(userId), server.domain + ); + } + + private async handleCertfp( + req: BridgeRequest, args: string[], sender: string, adminRoom: MatrixRoom): Promise { + const server = this.extractServerFromArgs(args); + if (!server.useSasl()) { + return new MatrixAction( + ActionType.Notice, + 'This bridge does not support SASL authentication, so you cannot store a certificate.', + ); + } + req.log.info(`${sender} is attempting to store a cert for ${server.domain}`); + await this.ircBridge.sendMatrixAction( + adminRoom, this.botUser, new MatrixAction( + ActionType.Notice, + `Please enter your certificate and private key (without formatting) for ${server.domain}.` + + " Say 'cancel' to cancel.", + ) + ); + let certfp: string; + try { + certfp = await new Promise((res, reject) => { + this.roomIdsExpectingCertFp.set(adminRoom.roomId, res) + setTimeout(() => { + reject(new Error('Timeout')); + }, CERT_FP_TIMEOUT_MS); + }); + } + catch (ex) { + return new MatrixAction( + ActionType.Notice, 'Timed out waiting for certificate', + ); + } + finally { + this.roomIdsExpectingCertFp.delete(adminRoom.roomId); + } + if (certfp.trim() === 'cancel') { + return new MatrixAction( + ActionType.Notice, `Request canceled.`, + ); + } + let privateKey, cert; + try { + const pair = getKeyPairFromString(certfp); + privateKey = pair.privateKey; + cert = pair.cert; + } + catch (ex) { + return new MatrixAction( + ActionType.Notice, `Could not parse keypair: ${ex.message}`, + ); + } + if (!cert.checkPrivateKey(privateKey)) { + return new MatrixAction( + ActionType.Notice, 'Public cert does not belong to private key.', + ); + } + const now = new Date(); + try { + const validFrom = new Date(cert.validFrom); + const validTo = new Date(cert.validTo); + + if (isNaN(validFrom.getTime())) { + return new MatrixAction( + ActionType.Notice, 'Certificate validFrom was invalid.', + ); + } + if (isNaN(validTo.getTime())) { + return new MatrixAction( + ActionType.Notice, 'Certificate validFrom was invalid.', + ); + } + + if (validFrom > now) { + return new MatrixAction( + ActionType.Notice, 'Certificate is not valid yet.', + ); + } + if (now > validTo) { + return new MatrixAction( + ActionType.Notice, 'Certificate has expired.', + ); + } + } + catch (ex) { + return new MatrixAction( + ActionType.Notice, 'Could not parse validFrom / validTo dates.', + ); + } + + + try { + const keypair = { + cert: cert.toString(), + key: privateKey.export({type: 'pkcs8', format: 'pem'}).toString(), + }; + await this.ircBridge.getStore().storeClientCert(sender, server.domain, keypair); + } + catch (ex) { + req.log.error('Unable to store certificate for user', ex); + return new MatrixAction( + ActionType.Notice, 'Error occured storing certificate. Unable to continue', + ); + } + + + return new MatrixAction( + ActionType.Notice, + `Successfully stored certificate for ${server.domain}. Use !reconnect to use this cert.` + ); + } + + private async handleRemoveCertfp(args: string[], userId: string) { + const ircServer = this.extractServerFromArgs(args); + + const domain = ircServer.domain; + + try { + await this.ircBridge.getStore().removeClientCert(userId, domain); + return new MatrixAction( + ActionType.Notice, `Successfully removed certfp.` + ); + } + catch (err) { + return new MatrixAction( + ActionType.Notice, `Failed to remove certfp: ${err.message}` + ); + } + } + private async handlePlumb(args: string[], sender: string) { const [matrixRoomId, serverDomain, ircChannel] = args; const server = serverDomain && this.ircBridge.getServer(serverDomain); @@ -529,7 +717,6 @@ export class AdminRoomHandler { const server = this.extractServerFromArgs(args); const domain = server.domain; - const store = this.ircBridge.getStore(); let notice; try { @@ -557,12 +744,7 @@ export class AdminRoomHandler { ); } else { - let config = await store.getIrcClientConfig(userId, server.domain); - if (!config) { - config = IrcClientConfig.newConfig( - new MatrixUser(userId), server.domain - ); - } + const config = await this.getOrCreateClientConfig(userId, server); config.setUsername(username); await this.ircBridge.getStore().storeIrcClientConfig(config); notice = new MatrixAction( diff --git a/src/datastore/DataStore.ts b/src/datastore/DataStore.ts index b1851ebc9..75df3a7c6 100644 --- a/src/datastore/DataStore.ts +++ b/src/datastore/DataStore.ts @@ -24,7 +24,7 @@ import { } from "matrix-appservice-bridge"; import { MatrixDirectoryVisibility } from "../bridge/IrcHandler"; import { IrcRoom } from "../models/IrcRoom"; -import { IrcClientConfig } from "../models/IrcClientConfig"; +import { IrcClientCertKeypair, IrcClientConfig } from "../models/IrcClientConfig"; import { IrcServer, IrcServerConfig } from "../irc/IrcServer"; export type RoomOrigin = "config"|"provision"|"alias"|"join"; @@ -175,6 +175,10 @@ export interface DataStore extends ProvisioningStore { removePass(userId: string, domain: string): Promise; + storeClientCert(userId: string, domain: string, keypair: IrcClientCertKeypair): Promise; + + removeClientCert(userId: string, domain: string): Promise; + getMatrixUserByUsername(domain: string, username: string): Promise; getCountForUsernamePrefix(domain: string, usernamePrefix: string): Promise; diff --git a/src/datastore/NedbDataStore.ts b/src/datastore/NedbDataStore.ts index 38406d933..96a94ce8d 100644 --- a/src/datastore/NedbDataStore.ts +++ b/src/datastore/NedbDataStore.ts @@ -16,7 +16,7 @@ limitations under the License. import { MatrixDirectoryVisibility } from "../bridge/IrcHandler"; import { IrcRoom } from "../models/IrcRoom"; -import { IrcClientConfig, IrcClientConfigSeralized } from "../models/IrcClientConfig" +import { IrcClientCertKeypair, IrcClientConfig, IrcClientConfigSeralized } from "../models/IrcClientConfig" import { getLogger } from "../logging"; import { @@ -82,12 +82,10 @@ export class NeDBDataStore implements DataStore { }, errLog("user id")); if (pkeyPath) { - this.cryptoStore = new StringCrypto(); - this.cryptoStore.load(pkeyPath); + this.cryptoStore = StringCrypto.fromFile(pkeyPath); } } - public async runMigrations() { const config = await this.userStore.getRemoteUser("config"); if (!config) { @@ -579,6 +577,17 @@ export class NeDBDataStore implements DataStore { log.warn(`Failed to decrypt password for ${userId} ${domain}`, ex); } } + if (configData.certificate && this.cryptoStore) { + try { + clientConfig.setCertificate({ + cert: configData.certificate.cert, + key: await this.cryptoStore.decryptLargeString(configData.certificate.key), + }) + } + catch (ex) { + log.warn(`Failed to decrypt TLS key for ${userId} ${domain}`, ex); + } + } return clientConfig; } @@ -608,7 +617,24 @@ export class NeDBDataStore implements DataStore { // Store the encrypted password, ready for the db config.setPassword(encryptedPass); } - userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize(); + const domainCfg = userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize(); + if (config.certificate) { + if (!this.cryptoStore) { + throw new Error( + 'Cannot store certificate' + ); + } + try { + domainCfg.certificate = { + cert: config.certificate.cert, + key: await this.cryptoStore.encryptLargeString(config.certificate.key), + }; + } + catch (ex) { + log.warn(`Failed to encrypt TLS key for ${userId} ${config.getDomain()}`, ex); + } + } + user.set("client_config", userConfig); await this.userStore.setMatrixUser(user); } @@ -675,6 +701,23 @@ export class NeDBDataStore implements DataStore { } } + public async storeClientCert(userId: string, domain: string, keypair: IrcClientCertKeypair): Promise { + const config = await this.getIrcClientConfig(userId, domain); + if (!config) { + throw new Error(`${userId} does not have an IRC client configured for ${domain}`); + } + config.setCertificate(keypair); + await this.storeIrcClientConfig(config); + } + + public async removeClientCert(userId: string, domain: string): Promise { + const config = await this.getIrcClientConfig(userId, domain); + if (config) { + config.setCertificate(); + await this.storeIrcClientConfig(config); + } + } + public async getMatrixUserByUsername(domain: string, username: string): Promise { const domainKey = domain.replace(/\./g, "_"); const matrixUsers = await this.userStore.getByMatrixData({ diff --git a/src/datastore/StringCrypto.ts b/src/datastore/StringCrypto.ts index b01f41354..877531ed3 100644 --- a/src/datastore/StringCrypto.ts +++ b/src/datastore/StringCrypto.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,23 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as crypto from "crypto"; +import { KeyObject, createCipheriv, createDecipheriv, createPrivateKey, privateDecrypt, + publicEncrypt, randomBytes, scrypt as scryptCb } from "node:crypto"; +import { promisify } from "node:util"; import * as fs from "fs"; import { getLogger } from "../logging"; +const scrypt = promisify(scryptCb); + const log = getLogger("CryptoStore"); +const algorithm = 'aes-256-cbc'; + +const SALT_ENCODING = 'base64'; +const SALT_BYTE_LENGTH = 16; +const SALT_STRING_LENGTH = Buffer.alloc(SALT_BYTE_LENGTH).toString(SALT_ENCODING).length; + +const ENCRYPTED_ENCODING = 'base64'; export class StringCrypto { - private privateKey!: string; - public load(pkeyPath: string) { + constructor(private readonly privateKey: KeyObject) { + if ((privateKey.asymmetricKeyDetails?.modulusLength || 0) < 2048) { + throw Error('Key size too small. Your passkey must be at least 2048 bits in length'); + } + } + + static fromFile(pkeyPath: string): StringCrypto { try { - this.privateKey = fs.readFileSync(pkeyPath, "utf8").toString(); + const privateKeyStr = fs.readFileSync(pkeyPath, "utf8").toString(); + const privateKey = createPrivateKey(privateKeyStr); // Test whether key is a valid PEM key (publicEncrypt does internal validation) try { - crypto.publicEncrypt( - this.privateKey, + publicEncrypt( + privateKeyStr, Buffer.from("This is a test!") ); } @@ -40,6 +57,7 @@ export class StringCrypto { } log.info(`Private key loaded from ${pkeyPath} - IRC password encryption enabled.`); + return new StringCrypto(privateKey); } catch (err) { log.error(`Could not load private key ${err.message}.`); @@ -48,19 +66,66 @@ export class StringCrypto { } public encrypt(plaintext: string): string { - const salt = crypto.randomBytes(16).toString('base64'); - return crypto.publicEncrypt( + const salt = randomBytes(SALT_BYTE_LENGTH).toString(SALT_ENCODING); + return publicEncrypt( this.privateKey, Buffer.from(salt + ' ' + plaintext) - ).toString('base64'); + ).toString(ENCRYPTED_ENCODING); } public decrypt(encryptedString: string): string { - const decryptedPass = crypto.privateDecrypt( + const decryptedPass = privateDecrypt( this.privateKey, - Buffer.from(encryptedString, 'base64') + Buffer.from(encryptedString, ENCRYPTED_ENCODING) ).toString(); // Extract the password by removing the prefixed salt and seperating space - return decryptedPass.split(' ')[1]; + return decryptedPass.slice(SALT_STRING_LENGTH + 1); + } + + public async encryptLargeString(plaintext: string): Promise { + const password = randomBytes(32).toString(ENCRYPTED_ENCODING); + const key = await scrypt(password, 'salt', 32) as Buffer; + const iv = randomBytes(16); + + const cipher = createCipheriv(algorithm, key, iv); + cipher.setEncoding(ENCRYPTED_ENCODING); + let encrypted = ''; + + // Large strings are encrypted as 'lg:encrypt($key_$iv):$encrypted_block' where the key_iv is further + // encrypted by the root private key. + const secret = this.encrypt(`${key.toString(ENCRYPTED_ENCODING)}_${iv.toString(ENCRYPTED_ENCODING)}`); + const streamPromise = new Promise((resolve, reject) => { + cipher.on('error', (err) => reject(err)); + cipher.on('end', () => resolve( + `lg:${secret}:${encrypted}` + )); + }); + + cipher.on('data', (chunk) => { encrypted += chunk }); + cipher.write(plaintext); + cipher.end(); + return streamPromise; + } + + public async decryptLargeString(encryptedString: string): Promise { + if (!encryptedString.startsWith('lg:')) { + throw Error('Not a large string'); + } + const [, keyPlusIvEnc, data] = encryptedString.split(':', 3); + const [keyB64, ivB64] = this.decrypt(keyPlusIvEnc).split('_'); + const iv = Buffer.from(ivB64, ENCRYPTED_ENCODING); + const key = Buffer.from(keyB64, ENCRYPTED_ENCODING); + + const decipher = createDecipheriv(algorithm, key, iv); + let decrypted = ''; + decipher.on('data', (chunk) => { decrypted += chunk }); + const streamPromise = new Promise((resolve, reject) => { + decipher.on('error', (err) => reject(err)); + decipher.on('end', () => resolve(decrypted)); + }); + + decipher.write(Buffer.from(data, ENCRYPTED_ENCODING)); + decipher.end(); + return streamPromise; } } diff --git a/src/datastore/postgres/PgDataStore.ts b/src/datastore/postgres/PgDataStore.ts index 4dbe3e8ec..576c30ce3 100644 --- a/src/datastore/postgres/PgDataStore.ts +++ b/src/datastore/postgres/PgDataStore.ts @@ -30,7 +30,7 @@ import { import { DataStore, RoomOrigin, ChannelMappings, UserFeatures } from "../DataStore"; import { MatrixDirectoryVisibility } from "../../bridge/IrcHandler"; import { IrcRoom } from "../../models/IrcRoom"; -import { IrcClientConfig } from "../../models/IrcClientConfig"; +import { IrcClientCertKeypair, IrcClientConfig, IrcClientConfigSeralized } from "../../models/IrcClientConfig"; import { IrcServer, IrcServerConfig } from "../../irc/IrcServer"; import { getLogger } from "../../logging"; @@ -57,7 +57,7 @@ interface RoomRecord { export class PgDataStore implements DataStore, ProvisioningStore { private serverMappings: {[domain: string]: IrcServer} = {}; - public static readonly LATEST_SCHEMA = 9; + public static readonly LATEST_SCHEMA = 10; private pgPool: Pool; private hasEnded = false; private cryptoStore?: StringCrypto; @@ -75,8 +75,7 @@ export class PgDataStore implements DataStore, ProvisioningStore { log.error("Postgres Error: %s", err); }); if (pkeyPath) { - this.cryptoStore = new StringCrypto(); - this.cryptoStore.load(pkeyPath); + this.cryptoStore = StringCrypto.fromFile(pkeyPath); } process.on("beforeExit", () => { if (this.hasEnded) { @@ -504,8 +503,13 @@ export class PgDataStore implements DataStore, ProvisioningStore { } public async getIrcClientConfig(userId: string, domain: string): Promise { - const res = await this.pgPool.query( - "SELECT config, password FROM client_config WHERE user_id = $1 and domain = $2", + const res = await this.pgPool.query<{ + config: IrcClientConfigSeralized, + password?: string, + cert?: string, + key?: string, + }>( + "SELECT config, password, cert, key FROM client_config WHERE user_id = $1 and domain = $2", [ userId, domain @@ -524,6 +528,19 @@ export class PgDataStore implements DataStore, ProvisioningStore { log.warn(`Failed to decrypt password for ${userId} ${domain}`, ex); } } + config.certificate = row.cert && row.key ? { + cert: row.cert, + key: '', + } : undefined; + const cryptoStore = this.cryptoStore; + if (config.certificate && row.key && cryptoStore) { + try { + config.certificate.key = await cryptoStore.decryptLargeString(row.key); + } + catch (ex) { + log.warn(`Failed to decrypt TLS key for ${userId} ${domain}`, ex); + } + } return new IrcClientConfig(userId, domain, config); } @@ -536,9 +553,12 @@ export class PgDataStore implements DataStore, ProvisioningStore { // We need to make sure we have a matrix user in the store. await this.pgPool.query("INSERT INTO matrix_users VALUES ($1, NULL) ON CONFLICT DO NOTHING", [userId]); let password = config.getPassword(); + + // This implies without a cryptostore these will be stored plain. if (password && this.cryptoStore) { password = this.cryptoStore.encrypt(password); } + const parameters = { user_id: userId, domain: config.getDomain(), @@ -640,6 +660,28 @@ export class PgDataStore implements DataStore, ProvisioningStore { [userId, domain]); } + public async storeClientCert(userId: string, domain: string, keypair: IrcClientCertKeypair): Promise { + if (!this.cryptoStore) { + throw Error("Password encryption is not configured.") + } + const key = await this.cryptoStore.encryptLargeString(keypair.key); + const parameters = { + user_id: userId, + domain, + cert: keypair.cert, + key, + }; + const statement = PgDataStore.BuildUpsertStatement("client_config", + "ON CONSTRAINT cons_client_config_unique", Object.keys(parameters)); + await this.pgPool.query(statement, Object.values(parameters)); + } + + public async removeClientCert(userId: string, domain: string): Promise { + await this.pgPool.query( + "UPDATE client_config SET cert = NULL AND key = NULL WHERE user_id = $1 AND domain = $2", + [userId, domain]); + } + public async getMatrixUserByUsername(domain: string, username: string): Promise { // This will need a join const res = await this.pgPool.query( diff --git a/src/datastore/postgres/schema/v10.ts b/src/datastore/postgres/schema/v10.ts new file mode 100644 index 000000000..4cd0b2d40 --- /dev/null +++ b/src/datastore/postgres/schema/v10.ts @@ -0,0 +1,9 @@ +import {PoolClient} from "pg"; + +export async function runSchema(connection: PoolClient) { + await connection.query(` + ALTER TABLE client_config + ADD COLUMN cert TEXT, + ADD COLUMN key TEXT;` + ); +} diff --git a/src/irc/BridgedClient.ts b/src/irc/BridgedClient.ts index 96bbce312..c3d1796e7 100644 --- a/src/irc/BridgedClient.ts +++ b/src/irc/BridgedClient.ts @@ -252,6 +252,7 @@ export class BridgedClient extends EventEmitter { localAddress: ( this.server.getIpv6Prefix() ? this.clientConfig.getIpv6Address() : undefined ), + certificate: this.clientConfig.certificate, useRedisPool: this.redisPool, encodingFallback: this.encodingFallback, }, diff --git a/src/irc/ConnectionInstance.ts b/src/irc/ConnectionInstance.ts index 29e5068cf..f21530989 100644 --- a/src/irc/ConnectionInstance.ts +++ b/src/irc/ConnectionInstance.ts @@ -61,6 +61,7 @@ function logError(err: Error) { export interface ConnectionOpts { localAddress?: string; password?: string; + certificate?: { cert: string, key: string}; realname: string; username?: string; nick: string; @@ -404,6 +405,22 @@ export class ConnectionInstance { if (!opts.nick || !server) { throw new Error("Bad inputs. Nick: " + opts.nick); } + + let saslType: undefined|"PLAIN"|"EXTERNAL"; + if (server.useSasl() && opts.password) { + saslType = "PLAIN"; + } + + const secure = server.useSsl() ? server.getSecureOptions() : undefined; + if (secure && opts.certificate && server.useSasl()) { + secure.requestCert = true; + secure.cert = opts.certificate.cert; + secure.key = opts.certificate.key; + saslType = "EXTERNAL"; + } + + log.debug(saslType ? `Connecting using ${saslType} auth` : 'Connecting without authentication'); + const connectionOpts: IrcClientOpts = { userName: opts.username, realName: opts.realname, @@ -419,9 +436,11 @@ export class ConnectionInstance { retryCount: 0, family: (server.getIpv6Prefix() || server.getIpv6Only() ? 6 : null) as 6|null, bustRfc3484: true, - sasl: opts.password ? server.useSasl() : false, - secure: server.useSsl() ? server.getSecureOptions() : undefined, + sasl: saslType ? server.useSasl() : false, + saslType: saslType, + secure: secure, encodingFallback: opts.encodingFallback, + debug: true, }; diff --git a/src/models/IrcClientConfig.ts b/src/models/IrcClientConfig.ts index efe386d2b..423d8650b 100644 --- a/src/models/IrcClientConfig.ts +++ b/src/models/IrcClientConfig.ts @@ -16,9 +16,15 @@ limitations under the License. import { MatrixUser } from "matrix-appservice-bridge"; +export interface IrcClientCertKeypair { + cert: string; + key: string; +} + export interface IrcClientConfigSeralized { username?: string; password?: string; + certificate?: IrcClientCertKeypair; nick?: string; ipv6?: string; } @@ -66,6 +72,14 @@ export class IrcClientConfig { return this.config.password; } + public setCertificate(keypair?: IrcClientCertKeypair) { + this.config.certificate = keypair; + } + + public get certificate(): IrcClientCertKeypair|undefined { + return this.config.certificate; + } + public setDesiredNick(nick: string) { this.config.nick = nick; } @@ -82,10 +96,11 @@ export class IrcClientConfig { return this.config.ipv6; } - public serialize(removePassword = false) { + public serialize(removePassword = false): IrcClientConfigSeralized { if (removePassword) { - const clone = JSON.parse(JSON.stringify(this.config)); + const clone: IrcClientConfigSeralized = JSON.parse(JSON.stringify(this.config)); delete clone.password; + delete clone.certificate; return clone; } return this.config; diff --git a/yarn.lock b/yarn.lock index 62291c1e0..575800728 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4309,10 +4309,10 @@ matrix-appservice@^2.0.0: request-promise "^4.2.6" sanitize-html "^2.8.0" -matrix-org-irc@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/matrix-org-irc/-/matrix-org-irc-2.1.0.tgz#513d284d081a01ef752a29cd410c11fce0c5d2c5" - integrity sha512-MV9Q8Mt8RTKf72U0D5zNbBL9P4JQJzU63HTeomguq6a+Zb5QZJvnBHfrdLJXtJyTsWGCtSbJ6rcSFJVrFmKUxA== +matrix-org-irc@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/matrix-org-irc/-/matrix-org-irc-2.2.0.tgz#ea5c732eedc5ae5620c80e03ae8c870a0a307315" + integrity sha512-ikd++87Na94ixLjZbCtWnh61t1ToSnx8JUPUechdIwL/h4cIj80TaxwsSC/Ef851nhi9PHeikT3ACQz8nYaprg== dependencies: chardet "^1.5.1" iconv-lite "^0.6.3" @@ -4893,20 +4893,25 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -pg-connection-string@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" - integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== +pg-cloudflare@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" + integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q== + +pg-connection-string@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475" + integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA== pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.0.tgz#3190df3e4747a0d23e5e9e8045bcd99bda0a712e" - integrity sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ== +pg-pool@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.1.tgz#5a902eda79a8d7e3c928b77abf776b3cb7d351f7" + integrity sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og== pg-protocol@*, pg-protocol@^1.6.0: version "1.6.0" @@ -4924,18 +4929,20 @@ pg-types@^2.1.0, pg-types@^2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.8.0: - version "8.10.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.10.0.tgz#5b8379c9b4a36451d110fc8cd98fc325fe62ad24" - integrity sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ== +pg@^8.11.0: + version "8.11.2" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.2.tgz#1a23f6de7bfb65ba56e4dd15df96668d319900c4" + integrity sha512-l4rmVeV8qTIrrPrIR3kZQqBgSN93331s9i6wiUiLOSk0Q7PmUxZD/m1rQI622l3NfqBby9Ar5PABfS/SulfieQ== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" - pg-connection-string "^2.5.0" - pg-pool "^3.6.0" + pg-connection-string "^2.6.2" + pg-pool "^3.6.1" pg-protocol "^1.6.0" pg-types "^2.1.0" pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.1" pgpass@1.x: version "1.0.5"