Skip to content

Commit 02e3982

Browse files
3nprob3nprob
authored andcommitted
Add SASL CertFP support for both bot and users
* Users can manage key and cert via ![store,remove][cert,key]` commands * Bot configured under botConfig
1 parent 8faf961 commit 02e3982

File tree

12 files changed

+321
-16
lines changed

12 files changed

+321
-16
lines changed

config.sample.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ ircService:
164164
# real matrix users in them, even if there is a mapping for the channel.
165165
# Default: true
166166
joinChannelsIfNoUsers: true
167+
#
168+
# Explicit key/cert to use when connecting. Optional.
169+
# When setting up with https://freenode.net/kb/answer/certfp , you can copy these from the .pem file
170+
#sslKey: |
171+
# -----BEGIN PRIVATE KEY-----
172+
# ...
173+
# -----END PRIVATE KEY-----
174+
#saslCert: |
175+
# -----BEGIN CERTIFICATE-----
176+
# ...
177+
# -----END CERTIFICATE-----
167178

168179
# Configuration for PMs / private 1:1 communications between users.
169180
privateMessages:

config.schema.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ properties:
257257
type: "string"
258258
joinChannelsIfNoUsers:
259259
type: "boolean"
260+
saslKey:
261+
type: "string"
262+
saslCert:
263+
type: "string"
260264
privateMessages:
261265
type: "object"
262266
properties:

src/bridge/AdminRoomHandler.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,22 @@ const COMMANDS: {[command: string]: Command|Heading} = {
9191
example: `!username [irc.example.net] username`,
9292
summary: "Store a username to use for future connections.",
9393
},
94+
"!storecert": {
95+
example: `!storecert irc.example.net] -----BEGIN CERTIFICATE-----[...]`,
96+
summary: `Store a SASL certificate for CertFP`,
97+
},
98+
"!storekey": {
99+
example: `!storekey [irc.example.net] -----BEGIN PRIVATE KEY-----[...]`,
100+
summary: `Store a SASL private key for CertFP`,
101+
},
102+
"!removecert": {
103+
example: `!removecert [irc.example.net]`,
104+
summary: `Remove a previously stored SASL certificate`,
105+
},
106+
"!removekey": {
107+
example: `!removekey [irc.example.net]`,
108+
summary: `Remove a previously stored SASL private key`,
109+
},
94110
'Info': { heading: true},
95111
"!bridgeversion": {
96112
example: `!bridgeversion`,
@@ -176,6 +192,14 @@ export class AdminRoomHandler {
176192
return await this.handleStorePass(req, args, event.sender);
177193
case "!removepass":
178194
return await this.handleRemovePass(args, event.sender);
195+
case "!storekey":
196+
return await this.handleStoreKey(req, args, event.sender);
197+
case "!storecert":
198+
return await this.handleStoreCert(req, args, event.sender);
199+
case "!removekey":
200+
return await this.handleRemoveKey(args, event.sender);
201+
case "!removecert":
202+
return await this.handleRemoveCert(args, event.sender);
179203
case "!listrooms":
180204
return await this.handleListRooms(args, event.sender);
181205
case "!quit":
@@ -469,7 +493,7 @@ export class AdminRoomHandler {
469493
let notice;
470494

471495
try {
472-
// Allow passwords with spaces
496+
// Allow usernames with spaces
473497
const username = args[0]?.trim();
474498
if (!username) {
475499
notice = new MatrixAction(
@@ -563,6 +587,103 @@ export class AdminRoomHandler {
563587
}
564588
}
565589

590+
private async handleStoreKey(req: BridgeRequest, args: string[], userId: string) {
591+
const server = this.extractServerFromArgs(args);
592+
const domain = server.domain;
593+
let notice;
594+
595+
try {
596+
const key = args.join(' ').replace(/(-----([A-Z ]*)-----)\s*/g, '\n$1\n').trim().replace('\n\n', '\n');
597+
if (key.length === 0) {
598+
notice = new MatrixAction(
599+
"notice",
600+
"Format: '!storekey key' or '!storepass irc.server.name key'\n"
601+
);
602+
}
603+
else {
604+
await this.ircBridge.getStore().storeKey(userId, domain, key);
605+
notice = new MatrixAction(
606+
"notice", `Successfully stored SASL key for ${domain}. Use !reconnect to reauthenticate.`
607+
);
608+
}
609+
}
610+
catch (err) {
611+
req.log.error(err.stack);
612+
return new MatrixAction(
613+
"notice", `Failed to store SASL key: ${err.message}`
614+
);
615+
}
616+
return notice;
617+
}
618+
619+
private async handleRemoveKey(args: string[], userId: string) {
620+
const server = this.extractServerFromArgs(args);
621+
622+
try {
623+
await this.ircBridge.getStore().removeKey(userId, server.domain);
624+
return new MatrixAction(
625+
"notice", `Successfully removed SASL key.`
626+
);
627+
}
628+
catch (err) {
629+
return new MatrixAction(
630+
"notice", `Failed to remove SASL key: ${err.message}`
631+
);
632+
}
633+
}
634+
635+
private async handleStoreCert(req: BridgeRequest, args: string[], userId: string) {
636+
const server = this.extractServerFromArgs(args);
637+
const domain = server.domain;
638+
let notice;
639+
640+
try {
641+
const cert = args.join(' ').replace(/(-----([A-Z ]*)-----)\s*/g, '\n$1\n').trim().replace('\n\n', '\n');
642+
if (cert.length === 0) {
643+
notice = new MatrixAction(
644+
"notice",
645+
"Format: '!storecert cert' or '!storecert irc.server.name cert'\n"
646+
);
647+
}
648+
else {
649+
let config = await this.ircBridge.getStore().getIrcClientConfig(userId, server.domain);
650+
if (!config) {
651+
config = IrcClientConfig.newConfig(
652+
new MatrixUser(userId), server.domain
653+
);
654+
}
655+
config.setSASLCert(cert);
656+
await this.ircBridge.getStore().storeIrcClientConfig(config);
657+
notice = new MatrixAction(
658+
"notice", `Successfully stored SASL cert for ${domain}. Use !reconnect to reauthenticate.`
659+
);
660+
}
661+
}
662+
catch (err) {
663+
req.log.error(err.stack);
664+
return new MatrixAction(
665+
"notice", `Failed to store SASL cert: ${err.message}`
666+
);
667+
}
668+
return notice;
669+
}
670+
671+
private async handleRemoveCert(args: string[], userId: string) {
672+
const server = this.extractServerFromArgs(args);
673+
674+
try {
675+
await this.ircBridge.getStore().removeCert(userId, server.domain);
676+
return new MatrixAction(
677+
"notice", `Successfully removed SASL cert.`
678+
);
679+
}
680+
catch (err) {
681+
return new MatrixAction(
682+
"notice", `Failed to remove SASL cert: ${err.message}`
683+
);
684+
}
685+
}
686+
566687
private async handleListRooms(args: string[], sender: string) {
567688
const server = this.extractServerFromArgs(args);
568689

src/datastore/DataStore.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ export interface DataStore {
169169

170170
removePass(userId: string, domain: string): Promise<void>;
171171

172+
storeKey(userId: string, domain: string, key: string): Promise<void>;
173+
174+
removeKey(userId: string, domain: string): Promise<void>;
175+
176+
storeCert(userId: string, domain: string, cert: string): Promise<void>;
177+
178+
removeCert(userId: string, domain: string): Promise<void>;
179+
172180
getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined>;
173181

174182
getCountForUsernamePrefix(domain: string, usernamePrefix: string): Promise<number>;

src/datastore/NedbDataStore.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,14 @@ export class NeDBDataStore implements DataStore {
575575
const decryptedPass = this.cryptoStore.decrypt(encryptedPass);
576576
clientConfig.setPassword(decryptedPass);
577577
}
578+
const encryptedKey = clientConfig.getSASLKey();
579+
if (encryptedKey) {
580+
if (!this.cryptoStore) {
581+
throw new Error(`Cannot decrypt SASL key of ${userId} - no private key`);
582+
}
583+
const decryptedKey = this.cryptoStore.decrypt(encryptedKey);
584+
clientConfig.setPassword(decryptedKey);
585+
}
578586
return clientConfig;
579587
}
580588

@@ -604,6 +612,16 @@ export class NeDBDataStore implements DataStore {
604612
// Store the encrypted password, ready for the db
605613
config.setPassword(encryptedPass);
606614
}
615+
const saslKey = config.getSASLKey();
616+
if (saslKey) {
617+
if (!this.cryptoStore) {
618+
throw new Error(
619+
'Cannot store plaintext private keys'
620+
);
621+
}
622+
const encryptedKey = this.cryptoStore.encrypt(saslKey);
623+
config.setSASLKey(encryptedKey);
624+
}
607625
userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize();
608626
user.set("client_config", userConfig);
609627
await this.userStore.setMatrixUser(user);
@@ -648,6 +666,39 @@ export class NeDBDataStore implements DataStore {
648666
}
649667
}
650668

669+
public async storeKey(userId: string, domain: string, key: string) {
670+
const config = await this.getIrcClientConfig(userId, domain);
671+
if (!config) {
672+
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
673+
}
674+
config.setSASLKey(key);
675+
await this.storeIrcClientConfig(config);
676+
}
677+
678+
public async removeKey(userId: string, domain: string) {
679+
const config = await this.getIrcClientConfig(userId, domain);
680+
if (config) {
681+
config.setSASLKey();
682+
await this.storeIrcClientConfig(config);
683+
}
684+
}
685+
686+
public async storeCert(userId: string, domain: string, cert: string) {
687+
const config = await this.getIrcClientConfig(userId, domain);
688+
if (!config) {
689+
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
690+
}
691+
config.setSASLCert(cert);
692+
await this.storeIrcClientConfig(config);
693+
}
694+
695+
public async removeCert(userId: string, domain: string) {
696+
const config = await this.getIrcClientConfig(userId, domain);
697+
if (config) {
698+
config.setSASLCert();
699+
await this.storeIrcClientConfig(config);
700+
}
701+
}
651702
public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
652703
const domainKey = domain.replace(/\./g, "_");
653704
const matrixUsers = await this.userStore.getByMatrixData({

src/datastore/postgres/PgDataStore.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ interface RoomRecord {
5454
export class PgDataStore implements DataStore {
5555
private serverMappings: {[domain: string]: IrcServer} = {};
5656

57-
public static readonly LATEST_SCHEMA = 8;
57+
public static readonly LATEST_SCHEMA = 9;
5858
private pgPool: Pool;
5959
private hasEnded = false;
6060
private cryptoStore?: StringCrypto;
@@ -502,7 +502,7 @@ export class PgDataStore implements DataStore {
502502

503503
public async getIrcClientConfig(userId: string, domain: string): Promise<IrcClientConfig | null> {
504504
const res = await this.pgPool.query(
505-
"SELECT config, password FROM client_config WHERE user_id = $1 and domain = $2",
505+
"SELECT config, password, sasl_cert, sasl_key FROM client_config WHERE user_id = $1 and domain = $2",
506506
[
507507
userId,
508508
domain
@@ -515,6 +515,10 @@ export class PgDataStore implements DataStore {
515515
if (row.password && this.cryptoStore) {
516516
config.password = this.cryptoStore.decrypt(row.password);
517517
}
518+
if (row.sasl_key && this.cryptoStore) {
519+
config.saslKey = this.cryptoStore.decrypt(row.sasl_key);
520+
}
521+
config.saslCert = row.sasl_cert;
518522
return new IrcClientConfig(userId, domain, config);
519523
}
520524

@@ -530,11 +534,16 @@ export class PgDataStore implements DataStore {
530534
if (password && this.cryptoStore) {
531535
password = this.cryptoStore.encrypt(password);
532536
}
537+
let saslKey = config.getSASLKey();
538+
if (saslKey && this.cryptoStore) {
539+
saslKey = this.cryptoStore.encrypt(saslKey);
540+
}
533541
const parameters = {
534542
user_id: userId,
535543
domain: config.getDomain(),
536-
// either use the decrypted password, or whatever is stored already.
537544
password,
545+
sasl_key: saslKey,
546+
sasl_cert: config.getSASLCert(),
538547
config: JSON.stringify(config.serialize(true)),
539548
};
540549
const statement = PgDataStore.BuildUpsertStatement(
@@ -613,6 +622,45 @@ export class PgDataStore implements DataStore {
613622
[userId, domain]);
614623
}
615624

625+
public async storeKey(userId: string, domain: string, key: string, encrypt = true): Promise<void> {
626+
let sasl_key = key;
627+
if (encrypt) {
628+
if (!this.cryptoStore) {
629+
throw Error("Password encryption is not configured.")
630+
}
631+
sasl_key = this.cryptoStore.encrypt(sasl_key);
632+
}
633+
const parameters = {
634+
user_id: userId,
635+
domain,
636+
sasl_key,
637+
};
638+
const statement = PgDataStore.BuildUpsertStatement("client_config",
639+
"ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
640+
await this.pgPool.query(statement, Object.values(parameters));
641+
}
642+
643+
public async removeKey(userId: string, domain: string): Promise<void> {
644+
await this.pgPool.query("UPDATE client_config SET sasl_key = NULL WHERE user_id = $1 AND domain = $2",
645+
[userId, domain]);
646+
}
647+
648+
public async storeCert(userId: string, domain: string, cert: string): Promise<void> {
649+
const parameters = {
650+
user_id: userId,
651+
domain,
652+
sasl_cert: cert,
653+
};
654+
const statement = PgDataStore.BuildUpsertStatement("client_config",
655+
"ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
656+
await this.pgPool.query(statement, Object.values(parameters));
657+
}
658+
659+
public async removeCert(userId: string, domain: string): Promise<void> {
660+
await this.pgPool.query("UPDATE client_config SET sasl_cert = NULL WHERE user_id = $1 AND domain = $2",
661+
[userId, domain]);
662+
}
663+
616664
public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
617665
// This will need a join
618666
const res = await this.pgPool.query(
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { PoolClient } from "pg";
2+
3+
export async function runSchema(connection: PoolClient) {
4+
await connection.query(`
5+
ALTER TABLE client_config ADD COLUMN sasl_cert TEXT;
6+
ALTER TABLE client_config ADD COLUMN sasl_key TEXT;
7+
`);
8+
}
9+

0 commit comments

Comments
 (0)