Skip to content

Commit 9716f7f

Browse files
author
3nprob
committed
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 1f4d0dc commit 9716f7f

File tree

12 files changed

+311
-15
lines changed

12 files changed

+311
-15
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
@@ -228,6 +228,10 @@ properties:
228228
type: "string"
229229
joinChannelsIfNoUsers:
230230
type: "boolean"
231+
saslKey:
232+
type: "string"
233+
saslCert:
234+
type: "string"
231235
privateMessages:
232236
type: "object"
233237
properties:

src/bridge/AdminRoomHandler.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ const COMMANDS: {[command: string]: Command|Heading} = {
8686
example: `!username [irc.example.net] username`,
8787
summary: "Store a username to use for future connections.",
8888
},
89+
"!storecert": {
90+
example: `!storecert irc.example.net] -----BEGIN CERTIFICATE-----[...]`,
91+
summary: `Store a SASL certificate for CertFP`,
92+
},
93+
"!storekey": {
94+
example: `!storekey [irc.example.net] -----BEGIN PRIVATE KEY-----[...]`,
95+
summary: `Store a SASL private key for CertFP`,
96+
},
97+
"!removecert": {
98+
example: `!removecert [irc.example.net]`,
99+
summary: `Remove a previously stored SASL certificate`,
100+
},
101+
"!removekey": {
102+
example: `!removekey [irc.example.net]`,
103+
summary: `Remove a previously stored SASL private key`,
104+
},
89105
'Info': { heading: true},
90106
"!bridgeversion": {
91107
example: `!bridgeversion`,
@@ -166,6 +182,14 @@ export class AdminRoomHandler {
166182
return await this.handleStorePass(req, args, event.sender);
167183
case "!removepass":
168184
return await this.handleRemovePass(args, event.sender);
185+
case "!storekey":
186+
return await this.handleStoreKey(req, args, event.sender);
187+
case "!storecert":
188+
return await this.handleStoreCert(req, args, event.sender);
189+
case "!removekey":
190+
return await this.handleRemoveKey(args, event.sender);
191+
case "!removecert":
192+
return await this.handleRemoveCert(args, event.sender);
169193
case "!listrooms":
170194
return await this.handleListRooms(args, event.sender);
171195
case "!quit":
@@ -463,7 +487,7 @@ export class AdminRoomHandler {
463487
let notice;
464488

465489
try {
466-
// Allow passwords with spaces
490+
// Allow usernames with spaces
467491
const username = args[0]?.trim();
468492
if (!username) {
469493
notice = new MatrixAction(
@@ -557,6 +581,96 @@ export class AdminRoomHandler {
557581
}
558582
}
559583

584+
private async handleStoreKey(req: BridgeRequest, args: string[], userId: string) {
585+
const server = this.extractServerFromArgs(args);
586+
const domain = server.domain;
587+
let notice;
588+
589+
try {
590+
const key = args.join('\n');
591+
if (key.length === 0) {
592+
notice = new MatrixAction(
593+
"notice",
594+
"Format: '!storekey key' or '!storepass irc.server.name key'\n"
595+
);
596+
}
597+
else {
598+
await this.ircBridge.getStore().storeKey(userId, domain, key);
599+
notice = new MatrixAction(
600+
"notice", `Successfully stored SASL key for ${domain}. Use !reconnect to reauthenticate.`
601+
);
602+
}
603+
}
604+
catch (err) {
605+
req.log.error(err.stack);
606+
return new MatrixAction(
607+
"notice", `Failed to store SASL key: ${err.message}`
608+
);
609+
}
610+
return notice;
611+
}
612+
613+
private async handleRemoveKey(args: string[], userId: string) {
614+
const server = this.extractServerFromArgs(args);
615+
616+
try {
617+
await this.ircBridge.getStore().removeKey(userId, server.domain);
618+
return new MatrixAction(
619+
"notice", `Successfully removed SASL key.`
620+
);
621+
}
622+
catch (err) {
623+
return new MatrixAction(
624+
"notice", `Failed to remove SASL key: ${err.message}`
625+
);
626+
}
627+
}
628+
629+
private async handleStoreCert(req: BridgeRequest, args: string[], userId: string) {
630+
const server = this.extractServerFromArgs(args);
631+
const domain = server.domain;
632+
let notice;
633+
634+
try {
635+
const cert = args.join('\n');
636+
if (cert.length === 0) {
637+
notice = new MatrixAction(
638+
"notice",
639+
"Format: '!storecert cert' or '!storecert irc.server.name cert'\n"
640+
);
641+
}
642+
else {
643+
await this.ircBridge.getStore().storeCert(userId, domain, cert);
644+
notice = new MatrixAction(
645+
"notice", `Successfully stored SASL cert for ${domain}. Use !reconnect to reauthenticate.`
646+
);
647+
}
648+
}
649+
catch (err) {
650+
req.log.error(err.stack);
651+
return new MatrixAction(
652+
"notice", `Failed to store SASL cert: ${err.message}`
653+
);
654+
}
655+
return notice;
656+
}
657+
658+
private async handleRemoveCert(args: string[], userId: string) {
659+
const server = this.extractServerFromArgs(args);
660+
661+
try {
662+
await this.ircBridge.getStore().removeCert(userId, server.domain);
663+
return new MatrixAction(
664+
"notice", `Successfully removed SASL cert.`
665+
);
666+
}
667+
catch (err) {
668+
return new MatrixAction(
669+
"notice", `Failed to remove SASL cert: ${err.message}`
670+
);
671+
}
672+
}
673+
560674
private async handleListRooms(args: string[], sender: string) {
561675
const server = this.extractServerFromArgs(args);
562676

src/datastore/DataStore.ts

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

163163
removePass(userId: string, domain: string): Promise<void>;
164164

165+
storeKey(userId: string, domain: string, key: string): Promise<void>;
166+
167+
removeKey(userId: string, domain: string): Promise<void>;
168+
169+
storeCert(userId: string, domain: string, cert: string): Promise<void>;
170+
171+
removeCert(userId: string, domain: string): Promise<void>;
172+
165173
getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined>;
166174

167175
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
@@ -542,6 +542,14 @@ export class NeDBDataStore implements DataStore {
542542
const decryptedPass = this.cryptoStore.decrypt(encryptedPass);
543543
clientConfig.setPassword(decryptedPass);
544544
}
545+
const encryptedKey = clientConfig.getSASLKey();
546+
if (encryptedKey) {
547+
if (!this.cryptoStore) {
548+
throw new Error(`Cannot decrypt SASL key of ${userId} - no private key`);
549+
}
550+
const decryptedKey = this.cryptoStore.decrypt(encryptedKey);
551+
clientConfig.setPassword(decryptedKey);
552+
}
545553
return clientConfig;
546554
}
547555

@@ -571,6 +579,16 @@ export class NeDBDataStore implements DataStore {
571579
// Store the encrypted password, ready for the db
572580
config.setPassword(encryptedPass);
573581
}
582+
const saslKey = config.getSASLKey();
583+
if (saslKey) {
584+
if (!this.cryptoStore) {
585+
throw new Error(
586+
'Cannot store plaintext private keys'
587+
);
588+
}
589+
const encryptedKey = this.cryptoStore.encrypt(saslKey);
590+
config.setSASLKey(encryptedKey);
591+
}
574592
userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize();
575593
user.set("client_config", userConfig);
576594
await this.userStore.setMatrixUser(user);
@@ -607,6 +625,39 @@ export class NeDBDataStore implements DataStore {
607625
}
608626
}
609627

628+
public async storeKey(userId: string, domain: string, key: string) {
629+
const config = await this.getIrcClientConfig(userId, domain);
630+
if (!config) {
631+
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
632+
}
633+
config.setSASLKey(key);
634+
await this.storeIrcClientConfig(config);
635+
}
636+
637+
public async removeKey(userId: string, domain: string) {
638+
const config = await this.getIrcClientConfig(userId, domain);
639+
if (config) {
640+
config.setSASLKey();
641+
await this.storeIrcClientConfig(config);
642+
}
643+
}
644+
645+
public async storeCert(userId: string, domain: string, cert: string) {
646+
const config = await this.getIrcClientConfig(userId, domain);
647+
if (!config) {
648+
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
649+
}
650+
config.setSASLCert(cert);
651+
await this.storeIrcClientConfig(config);
652+
}
653+
654+
public async removeCert(userId: string, domain: string) {
655+
const config = await this.getIrcClientConfig(userId, domain);
656+
if (config) {
657+
config.setSASLCert();
658+
await this.storeIrcClientConfig(config);
659+
}
660+
}
610661
public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
611662
const domainKey = domain.replace(/\./g, "_");
612663
const matrixUsers = await this.userStore.getByMatrixData({

src/datastore/postgres/PgDataStore.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface RoomRecord {
5252
export class PgDataStore implements DataStore {
5353
private serverMappings: {[domain: string]: IrcServer} = {};
5454

55-
public static readonly LATEST_SCHEMA = 6;
55+
public static readonly LATEST_SCHEMA = 7;
5656
private pgPool: Pool;
5757
private hasEnded = false;
5858
private cryptoStore?: StringCrypto;
@@ -485,7 +485,7 @@ export class PgDataStore implements DataStore {
485485

486486
public async getIrcClientConfig(userId: string, domain: string): Promise<IrcClientConfig | null> {
487487
const res = await this.pgPool.query(
488-
"SELECT config, password FROM client_config WHERE user_id = $1 and domain = $2",
488+
"SELECT config, password, sasl_key FROM client_config WHERE user_id = $1 and domain = $2",
489489
[
490490
userId,
491491
domain
@@ -498,6 +498,9 @@ export class PgDataStore implements DataStore {
498498
if (row.password && this.cryptoStore) {
499499
config.password = this.cryptoStore.decrypt(row.password);
500500
}
501+
if (row.sasl_key && this.cryptoStore) {
502+
config.saslKey = this.cryptoStore.decrypt(row.sasl_key);
503+
}
501504
return new IrcClientConfig(userId, domain, config);
502505
}
503506

@@ -513,11 +516,17 @@ export class PgDataStore implements DataStore {
513516
if (password && this.cryptoStore) {
514517
password = this.cryptoStore.encrypt(password);
515518
}
519+
let saslKey = config.getSASLKey();
520+
if (saslKey && this.cryptoStore) {
521+
saslKey = this.cryptoStore.encrypt(saslKey);
522+
}
516523
const parameters = {
517524
user_id: userId,
518525
domain: config.getDomain(),
519526
// either use the decrypted password, or whatever is stored already.
520527
password,
528+
sasl_key: saslKey,
529+
sasl_cert: config.getSASLCert(),
521530
config: JSON.stringify(config.serialize(true)),
522531
};
523532
const statement = PgDataStore.BuildUpsertStatement(
@@ -578,6 +587,45 @@ export class PgDataStore implements DataStore {
578587
[userId, domain]);
579588
}
580589

590+
public async storeKey(userId: string, domain: string, key: string, encrypt = true): Promise<void> {
591+
let sasl_key = key;
592+
if (encrypt) {
593+
if (!this.cryptoStore) {
594+
throw Error("Password encryption is not configured.")
595+
}
596+
sasl_key = this.cryptoStore.encrypt(sasl_key);
597+
}
598+
const parameters = {
599+
user_id: userId,
600+
domain,
601+
sasl_key,
602+
};
603+
const statement = PgDataStore.BuildUpsertStatement("client_config",
604+
"ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
605+
await this.pgPool.query(statement, Object.values(parameters));
606+
}
607+
608+
public async removeKey(userId: string, domain: string): Promise<void> {
609+
await this.pgPool.query("UPDATE client_config SET sasl_key = NULL WHERE user_id = $1 AND domain = $2",
610+
[userId, domain]);
611+
}
612+
613+
public async storeCert(userId: string, domain: string, cert: string): Promise<void> {
614+
const parameters = {
615+
user_id: userId,
616+
domain,
617+
sasl_cert: cert,
618+
};
619+
const statement = PgDataStore.BuildUpsertStatement("client_config",
620+
"ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
621+
await this.pgPool.query(statement, Object.values(parameters));
622+
}
623+
624+
public async removeCert(userId: string, domain: string): Promise<void> {
625+
await this.pgPool.query("UPDATE client_config SET sasl_cert = NULL WHERE user_id = $1 AND domain = $2",
626+
[userId, domain]);
627+
}
628+
581629
public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
582630
// This will need a join
583631
const res = await this.pgPool.query(
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
}

0 commit comments

Comments
 (0)