Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support authenticating via certfp #1757

Open
wants to merge 29 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4199fdd
Add cert FP database schema
Half-Shot Jul 28, 2023
790b9ec
Add support for certfp in connection instance
Half-Shot Jul 28, 2023
a1fc6ae
Pass through cert
Half-Shot Jul 28, 2023
a2279b7
Finish up support for certfp
Half-Shot Aug 8, 2023
dd24139
Linting
Half-Shot Aug 8, 2023
5e23fae
Update pg
Half-Shot Aug 8, 2023
3c72564
Add docs
Half-Shot Aug 8, 2023
92f7bc3
Don't log the connection opts
Half-Shot Aug 8, 2023
2e96194
Remove more debug statements
Half-Shot Aug 8, 2023
e1567e4
Fix if
Half-Shot Aug 8, 2023
4b73690
Allow users to cancel.
Half-Shot Aug 10, 2023
2af19e6
Add ability to remove cert too
Half-Shot Aug 10, 2023
2451eed
Simplify crypto logic.
Half-Shot Aug 10, 2023
ac708c0
Add NEDB support
Half-Shot Aug 10, 2023
23994cd
Add support for proper large string encrypted storage
Half-Shot Aug 10, 2023
5a757ad
Fully test the certfp code
Half-Shot Aug 10, 2023
0d8ba80
Commit cleanups.
Half-Shot Aug 10, 2023
5721149
Tidy ip
Half-Shot Aug 11, 2023
578131d
Add tests
Half-Shot Aug 11, 2023
9609692
Split out cert setting for performance reasons
Half-Shot Aug 11, 2023
6de6815
Update matrix-org-irc
Half-Shot Aug 11, 2023
85b1e35
Merge branch 'develop' into hs/cert-fp-initial
Half-Shot Aug 22, 2023
605bf0a
Fix typo in pg impl.
Half-Shot Aug 22, 2023
6c6c86c
Fixup tests
Half-Shot Aug 22, 2023
493fe5d
Wiggle it
Half-Shot Aug 22, 2023
d94fea6
Add port
Half-Shot Aug 22, 2023
b4e87e4
Tidy up PL logic
Half-Shot Aug 24, 2023
c0de3d2
Log connect / error
Half-Shot Aug 25, 2023
8229093
Better reporting for GHA
Half-Shot Aug 25, 2023
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
6 changes: 6 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -103,6 +105,7 @@ jobs:
image: ghcr.io/ergochat/ergo:stable
ports:
- 6667:6667
- 6697:6697
steps:
- name: Install Complement Dependencies
run: |
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -180,6 +185,7 @@ jobs:
image: ghcr.io/ergochat/ergo:stable
ports:
- 6667:6667
- 6697:6697
steps:
- name: Install Complement Dependencies
run: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1757.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for authenticating via CertFP ([RFC4422](https://datatracker.ietf.org/doc/html/rfc4422#appendix-A)).
20 changes: 20 additions & 0 deletions docs/admin_room.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions spec/e2e/authentication.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getKeyPairFromString>>((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<typeof getKeyPairFromString>;
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}`
});

});
});
2 changes: 2 additions & 0 deletions spec/e2e/jest.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/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
Expand Down
8 changes: 6 additions & 2 deletions spec/e2e/powerlevels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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, {
Expand All @@ -44,7 +48,7 @@ describe('Ensure powerlevels are appropriately applied', () => {
},
);

await charlieJoinPromise;

await bob.send('MODE', channel, '+o', charlie.nick);
expect(await plEvent).toBeDefined();
});
Expand Down
41 changes: 41 additions & 0 deletions spec/unit/StringCrypto.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 0 additions & 1 deletion spec/unit/pool-service/IrcClientRedisState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
32 changes: 27 additions & 5 deletions spec/util/e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -74,7 +75,7 @@ export class E2ETestMatrixClient extends MatrixClient {
}

public async waitForRoomEvent<T extends object = Record<string, unknown>>(
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,
}}> {
Expand All @@ -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 ?? ''}`
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -290,7 +301,8 @@ export class IrcBridgeE2ETest {
debugApi: {
enabled: false,
port: 0,
}
},
passwordEncryptionKeyPath: './spec/support/passkey.pem',
},
...config,
...(redisUri && { connectionPool: {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading