Skip to content

Commit

Permalink
Add SASL CertFP support for both bot and users
Browse files Browse the repository at this point in the history
* Users can manage key and cert via ![store,remove][cert,key]` commands
* Bot configured under botConfig
  • Loading branch information
3nprob committed Aug 18, 2021
1 parent 1f4d0dc commit 430c6e3
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 15 deletions.
116 changes: 115 additions & 1 deletion src/bridge/AdminRoomHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ const COMMANDS: {[command: string]: Command|Heading} = {
example: `!username [irc.example.net] username`,
summary: "Store a username to use for future connections.",
},
"!storecert": {
example: `!storecert irc.example.net] -----BEGIN CERTIFICATE-----[...]`,
summary: `Store a SASL certificate for CertFP`,
},
"!storekey": {
example: `!storekey [irc.example.net] -----BEGIN PRIVATE KEY-----[...]`,
summary: `Store a SASL private key for CertFP`,
},
"!removecert": {
example: `!removecert [irc.example.net]`,
summary: `Remove a previously stored SASL certificate`,
},
"!removekey": {
example: `!removekey [irc.example.net]`,
summary: `Remove a previously stored SASL private key`,
},
'Info': { heading: true},
"!bridgeversion": {
example: `!bridgeversion`,
Expand Down Expand Up @@ -166,6 +182,14 @@ export class AdminRoomHandler {
return await this.handleStorePass(req, args, event.sender);
case "!removepass":
return await this.handleRemovePass(args, event.sender);
case "!storekey":
return await this.handleStoreKey(req, args, event.sender);
case "!storecert":
return await this.handleStoreCert(req, args, event.sender);
case "!removekey":
return await this.handleRemoveKey(args, event.sender);
case "!removecert":
return await this.handleRemoveCert(args, event.sender);
case "!listrooms":
return await this.handleListRooms(args, event.sender);
case "!quit":
Expand Down Expand Up @@ -463,7 +487,7 @@ export class AdminRoomHandler {
let notice;

try {
// Allow passwords with spaces
// Allow usernames with spaces
const username = args[0]?.trim();
if (!username) {
notice = new MatrixAction(
Expand Down Expand Up @@ -557,6 +581,96 @@ export class AdminRoomHandler {
}
}

private async handleStoreKey(req: BridgeRequest, args: string[], userId: string) {
const server = this.extractServerFromArgs(args);
const domain = server.domain;
let notice;

try {
const key = args.join('\n');
if (key.length === 0) {
notice = new MatrixAction(
"notice",
"Format: '!storekey key' or '!storepass irc.server.name key'\n"
);
}
else {
await this.ircBridge.getStore().storeKey(userId, domain, key);
notice = new MatrixAction(
"notice", `Successfully stored SASL key for ${domain}. Use !reconnect to reauthenticate.`
);
}
}
catch (err) {
req.log.error(err.stack);
return new MatrixAction(
"notice", `Failed to store SASL key: ${err.message}`
);
}
return notice;
}

private async handleRemoveKey(args: string[], userId: string) {
const server = this.extractServerFromArgs(args);

try {
await this.ircBridge.getStore().removeKey(userId, server.domain);
return new MatrixAction(
"notice", `Successfully removed SASL key.`
);
}
catch (err) {
return new MatrixAction(
"notice", `Failed to remove SASL key: ${err.message}`
);
}
}

private async handleStoreCert(req: BridgeRequest, args: string[], userId: string) {
const server = this.extractServerFromArgs(args);
const domain = server.domain;
let notice;

try {
const cert = args.join('\n');
if (cert.length === 0) {
notice = new MatrixAction(
"notice",
"Format: '!storecert cert' or '!storecert irc.server.name cert'\n"
);
}
else {
await this.ircBridge.getStore().storeCert(userId, domain, cert);
notice = new MatrixAction(
"notice", `Successfully stored SASL cert for ${domain}. Use !reconnect to reauthenticate.`
);
}
}
catch (err) {
req.log.error(err.stack);
return new MatrixAction(
"notice", `Failed to store SASL cert: ${err.message}`
);
}
return notice;
}

private async handleRemoveCert(args: string[], userId: string) {
const server = this.extractServerFromArgs(args);

try {
await this.ircBridge.getStore().removeCert(userId, server.domain);
return new MatrixAction(
"notice", `Successfully removed SASL cert.`
);
}
catch (err) {
return new MatrixAction(
"notice", `Failed to remove SASL cert: ${err.message}`
);
}
}

private async handleListRooms(args: string[], sender: string) {
const server = this.extractServerFromArgs(args);

Expand Down
8 changes: 8 additions & 0 deletions src/datastore/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ export interface DataStore {

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

storeKey(userId: string, domain: string, key: string): Promise<void>;

removeKey(userId: string, domain: string): Promise<void>;

storeCert(userId: string, domain: string, cert: string): Promise<void>;

removeCert(userId: string, domain: string): Promise<void>;

getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined>;

getCountForUsernamePrefix(domain: string, usernamePrefix: string): Promise<number>;
Expand Down
51 changes: 51 additions & 0 deletions src/datastore/NedbDataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,14 @@ export class NeDBDataStore implements DataStore {
const decryptedPass = this.cryptoStore.decrypt(encryptedPass);
clientConfig.setPassword(decryptedPass);
}
const encryptedKey = clientConfig.getSASLKey();
if (encryptedKey) {
if (!this.cryptoStore) {
throw new Error(`Cannot decrypt SASL key of ${userId} - no private key`);
}
const decryptedKey = this.cryptoStore.decrypt(encryptedKey);
clientConfig.setPassword(decryptedKey);
}
return clientConfig;
}

Expand Down Expand Up @@ -571,6 +579,16 @@ export class NeDBDataStore implements DataStore {
// Store the encrypted password, ready for the db
config.setPassword(encryptedPass);
}
const saslKey = config.getSASLKey();
if (saslKey) {
if (!this.cryptoStore) {
throw new Error(
'Cannot store plaintext private keys'
);
}
const encryptedKey = this.cryptoStore.encrypt(saslKey);
config.setSASLKey(encryptedKey);
}
userConfig[config.getDomain().replace(/\./g, "_")] = config.serialize();
user.set("client_config", userConfig);
await this.userStore.setMatrixUser(user);
Expand Down Expand Up @@ -607,6 +625,39 @@ export class NeDBDataStore implements DataStore {
}
}

public async storeKey(userId: string, domain: string, key: string) {
const config = await this.getIrcClientConfig(userId, domain);
if (!config) {
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
}
config.setSASLKey(key);
await this.storeIrcClientConfig(config);
}

public async removeKey(userId: string, domain: string) {
const config = await this.getIrcClientConfig(userId, domain);
if (config) {
config.setSASLKey();
await this.storeIrcClientConfig(config);
}
}

public async storeCert(userId: string, domain: string, cert: string) {
const config = await this.getIrcClientConfig(userId, domain);
if (!config) {
throw new Error(`${userId} does not have an IRC client configured for ${domain}`);
}
config.setSASLCert(cert);
await this.storeIrcClientConfig(config);
}

public async removeCert(userId: string, domain: string) {
const config = await this.getIrcClientConfig(userId, domain);
if (config) {
config.setSASLCert();
await this.storeIrcClientConfig(config);
}
}
public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
const domainKey = domain.replace(/\./g, "_");
const matrixUsers = await this.userStore.getByMatrixData({
Expand Down
52 changes: 50 additions & 2 deletions src/datastore/postgres/PgDataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface RoomRecord {
export class PgDataStore implements DataStore {
private serverMappings: {[domain: string]: IrcServer} = {};

public static readonly LATEST_SCHEMA = 6;
public static readonly LATEST_SCHEMA = 7;
private pgPool: Pool;
private hasEnded = false;
private cryptoStore?: StringCrypto;
Expand Down Expand Up @@ -485,7 +485,7 @@ export class PgDataStore implements DataStore {

public async getIrcClientConfig(userId: string, domain: string): Promise<IrcClientConfig | null> {
const res = await this.pgPool.query(
"SELECT config, password FROM client_config WHERE user_id = $1 and domain = $2",
"SELECT config, password, sasl_key FROM client_config WHERE user_id = $1 and domain = $2",
[
userId,
domain
Expand All @@ -498,6 +498,9 @@ export class PgDataStore implements DataStore {
if (row.password && this.cryptoStore) {
config.password = this.cryptoStore.decrypt(row.password);
}
if (row.sasl_key && this.cryptoStore) {
config.saslKey = this.cryptoStore.decrypt(row.sasl_key);
}
return new IrcClientConfig(userId, domain, config);
}

Expand All @@ -513,11 +516,17 @@ export class PgDataStore implements DataStore {
if (password && this.cryptoStore) {
password = this.cryptoStore.encrypt(password);
}
let saslKey = config.getSASLKey();
if (saslKey && this.cryptoStore) {
saslKey = this.cryptoStore.encrypt(saslKey);
}
const parameters = {
user_id: userId,
domain: config.getDomain(),
// either use the decrypted password, or whatever is stored already.
password,
sasl_key: saslKey,
sasl_cert: config.getSASLCert(),
config: JSON.stringify(config.serialize(true)),
};
const statement = PgDataStore.BuildUpsertStatement(
Expand Down Expand Up @@ -578,6 +587,45 @@ export class PgDataStore implements DataStore {
[userId, domain]);
}

public async storeKey(userId: string, domain: string, key: string, encrypt = true): Promise<void> {
let sasl_key = key;
if (encrypt) {
if (!this.cryptoStore) {
throw Error("Password encryption is not configured.")
}
sasl_key = this.cryptoStore.encrypt(sasl_key);
}
const parameters = {
user_id: userId,
domain,
sasl_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 removeKey(userId: string, domain: string): Promise<void> {
await this.pgPool.query("UPDATE client_config SET sasl_key = NULL WHERE user_id = $1 AND domain = $2",
[userId, domain]);
}

public async storeCert(userId: string, domain: string, cert: string): Promise<void> {
const parameters = {
user_id: userId,
domain,
sasl_cert: cert,
};
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 removeCert(userId: string, domain: string): Promise<void> {
await this.pgPool.query("UPDATE client_config SET sasl_cert = NULL WHERE user_id = $1 AND domain = $2",
[userId, domain]);
}

public async getMatrixUserByUsername(domain: string, username: string): Promise<MatrixUser|undefined> {
// This will need a join
const res = await this.pgPool.query(
Expand Down
8 changes: 8 additions & 0 deletions src/datastore/postgres/schema/v7.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { PoolClient } from "pg";

export async function runSchema(connection: PoolClient) {
await connection.query(`
ALTER TABLE client_config ADD COLUMN sasl_cert TEXT;
ALTER TABLE client_config ADD COLUMN sasl_key TEXT;
`);
}
19 changes: 16 additions & 3 deletions src/irc/BridgedClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export class BridgedClient extends EventEmitter {
private _nick: string;
public readonly id: string;
private readonly password?: string;
private readonly saslKey?: string;
private readonly saslCert?: string;
private lastActionTs: number;
private _explicitDisconnect = false;
private _disconnectReason: string|null = null;
Expand Down Expand Up @@ -135,9 +137,16 @@ export class BridgedClient extends EventEmitter {
throw Error("Could not determine nick for user");
}
this._nick = BridgedClient.getValidNick(chosenNick, false, this.state);
this.password = (
clientConfig.getPassword() ? clientConfig.getPassword() : server.config.password
);
if (isBot) {
this.password = clientConfig.getPassword() || server.config.password;
this.saslKey = clientConfig.getSASLKey() || server.config.botConfig.saslKey;
this.saslCert = clientConfig.getSASLCert() || server.config.botConfig.saslCert;
}
else {
this.password = clientConfig.getPassword();
this.saslKey = clientConfig.getSASLKey();
this.saslCert = clientConfig.getSASLCert();
}

this.lastActionTs = Date.now();
this.connectDefer = promiseutil.defer();
Expand Down Expand Up @@ -250,6 +259,10 @@ export class BridgedClient extends EventEmitter {
this.server.getIpv6Prefix() ? this.clientConfig.getIpv6Address() : undefined
),
encodingFallback: this.encodingFallback,
secure: {
key: this.saslKey,
cert: this.saslCert,
},
}, (inst: ConnectionInstance) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.onConnectionCreated(inst, nameInfo, identResolver!);
Expand Down
Loading

0 comments on commit 430c6e3

Please sign in to comment.