Skip to content

Commit 008c5ee

Browse files
committed
Finish up support for certfp
1 parent a7c4374 commit 008c5ee

File tree

4 files changed

+197
-35
lines changed

4 files changed

+197
-35
lines changed

src/bridge/AdminRoomHandler.ts

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getBridgeVersion } from "matrix-appservice-bridge";
2828
import { Provisioner } from "../provisioning/Provisioner";
2929
import { IrcProvisioningError } from "../provisioning/Schema";
3030
import { validateChannelName } from "../models/IrcRoom";
31+
import { X509Certificate, createPrivateKey } from "node:crypto";
3132

3233
const log = logging("AdminRoomHandler");
3334

@@ -61,6 +62,33 @@ export function parseCommandFromEvent(event: { content?: { body?: unknown }}, pr
6162
return { cmd, args };
6263
}
6364

65+
66+
67+
export function getKeyPairFromString(keypairString: string) {
68+
const keyStart = keypairString.indexOf('-----BEGIN PRIVATE KEY-----');
69+
const keyEnd = keypairString.indexOf('-----END PRIVATE KEY-----');
70+
const certStart = keypairString.indexOf('-----BEGIN CERTIFICATE-----');
71+
const certEnd = keypairString.indexOf('-----END CERTIFICATE-----');
72+
if (certStart === -1) {
73+
throw Error('Missing BEGIN CERTIFICATE');
74+
}
75+
if (certEnd === -1) {
76+
throw Error('Missing END CERTIFICATE');
77+
}
78+
if (keyStart === -1) {
79+
throw Error('Missing BEGIN PRIVATE KEY');
80+
}
81+
if (keyEnd === -1) {
82+
throw Error('Missing END PRIVATE KEY');
83+
}
84+
const certStr = keypairString.slice(certStart, certEnd + '-----END CERTIFICATE-----'.length);
85+
const privateKeyStr = keypairString.slice(keyStart, keyEnd + '-----END CERTIFICATE-----'.length);
86+
const privateKey = createPrivateKey(privateKeyStr);
87+
const cert = new X509Certificate(certStr);
88+
return { cert, privateKey };
89+
90+
}
91+
6492
// This is just a length to avoid silly long usernames
6593
const SANE_USERNAME_LENGTH = 64;
6694

@@ -75,6 +103,8 @@ const SANE_USERNAME_LENGTH = 64;
75103
// (0x00 to 0x1F, plus DEL (0x7F)), as they are most likely mistakes.
76104
const SASL_USERNAME_INVALID_CHARS_PATTERN = /[\x00-\x20\x7F]+/; // eslint-disable-line
77105

106+
const CERT_FP_TIMEOUT_MS = 90 * 1000;
107+
78108
interface Command {
79109
example: string;
80110
summary: string;
@@ -87,6 +117,10 @@ interface Heading {
87117

88118
const COMMANDS: {[command: string]: Command|Heading} = {
89119
'Actions': { heading: true },
120+
"certfp": {
121+
example: `!certfp [irc.example.net]`,
122+
summary: `Store a certificate for authenticating with the remote network`,
123+
},
90124
"cmd": {
91125
example: `!cmd [irc.example.net] COMMAND [arg0 [arg1 [...]]]`,
92126
summary: "Issue a raw IRC command. These will not produce a reply." +
@@ -166,19 +200,28 @@ class ServerRequiredError extends Error {
166200
const USER_FEATURES = ["mentions"];
167201
export class AdminRoomHandler {
168202
private readonly botUser: MatrixUser;
203+
private readonly roomIdsExpectingCertFp = new Map<string, (certificate: string) => void>();
169204
constructor(private ircBridge: IrcBridge, private matrixHandler: MatrixHandler) {
170205
this.botUser = new MatrixUser(ircBridge.appServiceUserId, undefined, false);
171206
}
172207

173208
public async onAdminMessage(req: BridgeRequest, event: MatrixSimpleMessage, adminRoom: MatrixRoom) {
174209
req.log.info("Handling admin command from %s", event.sender);
210+
211+
const certFpFunction = this.roomIdsExpectingCertFp.get(adminRoom.roomId);
212+
if (certFpFunction) {
213+
this.roomIdsExpectingCertFp.delete(adminRoom.roomId);
214+
certFpFunction(event.content.body);
215+
return;
216+
}
217+
175218
const parseResult = parseCommandFromEvent(event);
176219
let response: MatrixAction|void;
177220
if (parseResult) {
178221
const { cmd, args } = parseResult;
179222

180223
try {
181-
response = await this.handleCommand(cmd, args, req, event);
224+
response = await this.handleCommand(cmd, args, req, event, adminRoom);
182225
}
183226
catch (err) {
184227
if (err instanceof ServerRequiredError) {
@@ -203,7 +246,9 @@ export class AdminRoomHandler {
203246
}
204247
}
205248

206-
private async handleCommand(cmd: string, args: string[], req: BridgeRequest, event: MatrixSimpleMessage) {
249+
private async handleCommand(
250+
cmd: string, args: string[], req: BridgeRequest, event: MatrixSimpleMessage, adminRoom: MatrixRoom
251+
) {
207252
const userPermission = this.getUserPermission(event.sender);
208253
const requiredPermission = (COMMANDS[cmd] as Command|undefined)?.requiresPermission;
209254
if (requiredPermission && requiredPermission > userPermission) {
@@ -216,6 +261,8 @@ export class AdminRoomHandler {
216261
return new MatrixAction(ActionType.Notice, "You have been marked as active by the bridge");
217262
case "join":
218263
return await this.handleJoin(req, args, event.sender);
264+
case "certfp":
265+
return await this.handleCertfp(req, args, event.sender, adminRoom);
219266
case "cmd":
220267
return await this.handleCmd(req, args, event.sender);
221268
case "whois":
@@ -252,6 +299,106 @@ export class AdminRoomHandler {
252299
}
253300
}
254301

302+
private async getOrCreateClientConfig(userId: string, server: IrcServer) {
303+
const store = this.ircBridge.getStore();
304+
const config = await store.getIrcClientConfig(userId, server.domain);
305+
if (config) {
306+
return config;
307+
}
308+
return IrcClientConfig.newConfig(
309+
new MatrixUser(userId), server.domain
310+
);
311+
}
312+
313+
private async handleCertfp(
314+
req: BridgeRequest, args: string[], sender: string, adminRoom: MatrixRoom): Promise<MatrixAction> {
315+
const server = this.extractServerFromArgs(args);
316+
req.log.info(`${sender} is attempting to store a cert for ${server.domain}`);
317+
await this.ircBridge.sendMatrixAction(
318+
adminRoom, this.botUser, new MatrixAction(
319+
ActionType.Notice, `Please enter your certificate and private key (without formatting) for ${server.getReadableName()}.`
320+
)
321+
);
322+
let certfp: string;
323+
try {
324+
certfp = await new Promise<string>((res, reject) => {
325+
this.roomIdsExpectingCertFp.set(adminRoom.roomId, res)
326+
setTimeout(() => {
327+
reject(new Error('Timeout'));
328+
}, CERT_FP_TIMEOUT_MS);
329+
});
330+
}
331+
catch (ex) {
332+
return new MatrixAction(
333+
ActionType.Notice, 'Timed out waiting for certificate',
334+
);
335+
}
336+
finally {
337+
this.roomIdsExpectingCertFp.delete(adminRoom.roomId);
338+
}
339+
let privateKey, cert;
340+
try {
341+
const pair = getKeyPairFromString(certfp);
342+
privateKey = pair.privateKey;
343+
cert = pair.cert;
344+
}
345+
catch (ex) {
346+
return new MatrixAction(
347+
ActionType.Notice, `Could not parse keypair: ${ex.message}`,
348+
);
349+
}
350+
if (!cert.checkPrivateKey(privateKey)) {
351+
return new MatrixAction(
352+
ActionType.Notice, 'Public cert does not belong to private key.',
353+
);
354+
}
355+
const now = new Date();
356+
try {
357+
const validFrom = new Date(cert.validFrom);
358+
const validTo = new Date(cert.validTo);
359+
360+
if (isNaN(validFrom.getTime())) {
361+
return new MatrixAction(
362+
ActionType.Notice, 'Certificate validFrom was invalid.',
363+
);
364+
}
365+
if (isNaN(validTo.getTime())) {
366+
return new MatrixAction(
367+
ActionType.Notice, 'Certificate validFrom was invalid.',
368+
);
369+
}
370+
371+
if (validFrom > now) {
372+
return new MatrixAction(
373+
ActionType.Notice, 'Certificate is not valid yet.',
374+
);
375+
}
376+
if (now > validTo) {
377+
return new MatrixAction(
378+
ActionType.Notice, 'Certificate has expired.',
379+
);
380+
}
381+
}
382+
catch (ex) {
383+
return new MatrixAction(
384+
ActionType.Notice, 'Could not parse validFrom / validTo dates.',
385+
);
386+
}
387+
388+
const clientConfig = await this.getOrCreateClientConfig(sender, server);
389+
clientConfig.setCertificate({
390+
cert: cert.toString(),
391+
key: privateKey.export({type: 'pkcs8', format: 'pem'}).toString(),
392+
});
393+
await this.ircBridge.getStore().storeIrcClientConfig(clientConfig);
394+
395+
396+
return new MatrixAction(
397+
ActionType.Notice,
398+
`Successfully stored certificate for ${server.domain}. Use !reconnect to use this cert.`
399+
);
400+
}
401+
255402
private async handlePlumb(args: string[], sender: string) {
256403
const [matrixRoomId, serverDomain, ircChannel] = args;
257404
const server = serverDomain && this.ircBridge.getServer(serverDomain);
@@ -557,12 +704,7 @@ export class AdminRoomHandler {
557704
);
558705
}
559706
else {
560-
let config = await store.getIrcClientConfig(userId, server.domain);
561-
if (!config) {
562-
config = IrcClientConfig.newConfig(
563-
new MatrixUser(userId), server.domain
564-
);
565-
}
707+
const config = await this.getOrCreateClientConfig(userId, server);
566708
config.setUsername(username);
567709
await this.ircBridge.getStore().storeIrcClientConfig(config);
568710
notice = new MatrixAction(

src/datastore/postgres/PgDataStore.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
import { DataStore, RoomOrigin, ChannelMappings, UserFeatures } from "../DataStore";
3131
import { MatrixDirectoryVisibility } from "../../bridge/IrcHandler";
3232
import { IrcRoom } from "../../models/IrcRoom";
33-
import { IrcClientConfig } from "../../models/IrcClientConfig";
33+
import { IrcClientConfig, IrcClientConfigSeralized } from "../../models/IrcClientConfig";
3434
import { IrcServer, IrcServerConfig } from "../../irc/IrcServer";
3535

3636
import { getLogger } from "../../logging";
@@ -504,7 +504,12 @@ export class PgDataStore implements DataStore, ProvisioningStore {
504504
}
505505

506506
public async getIrcClientConfig(userId: string, domain: string): Promise<IrcClientConfig | null> {
507-
const res = await this.pgPool.query(
507+
const res = await this.pgPool.query<{
508+
config: IrcClientConfigSeralized,
509+
password: string,
510+
cert: string,
511+
key: string,
512+
}>(
508513
"SELECT config, password, cert, key FROM client_config WHERE user_id = $1 and domain = $2",
509514
[
510515
userId,
@@ -528,17 +533,15 @@ export class PgDataStore implements DataStore, ProvisioningStore {
528533
cert: row.cert,
529534
key: row.key,
530535
};
531-
// TODO: Testing
532-
if (row.cert && row.key && this.cryptoStore) {
533-
// NOT fatal, but really worrying.
536+
const cryptoStore = this.cryptoStore;
537+
if (row.key && cryptoStore) {
534538
try {
535-
config.certificate = {
536-
cert: this.cryptoStore.decrypt(row.cert),
537-
key: this.cryptoStore.decrypt(row.key)
538-
};
539+
const keyParts = row.key.split(',').map(v => cryptoStore.decrypt(v));
540+
config.certificate.key = `-----BEGIN PRIVATE KEY-----\n${keyParts.join('')}-----END PRIVATE KEY-----\n`;
541+
console.log(keyParts);
539542
}
540543
catch (ex) {
541-
log.warn(`Failed to decrypt certificate for ${userId} ${domain}`, ex);
544+
log.warn(`Failed to decrypt TLS key for ${userId} ${domain}`, ex);
542545
}
543546
}
544547
return new IrcClientConfig(userId, domain, config);
@@ -553,23 +556,36 @@ export class PgDataStore implements DataStore, ProvisioningStore {
553556
// We need to make sure we have a matrix user in the store.
554557
await this.pgPool.query("INSERT INTO matrix_users VALUES ($1, NULL) ON CONFLICT DO NOTHING", [userId]);
555558
let password = config.getPassword();
556-
const cert: {cert?: string, key?: string} = { };
559+
const keypair: {cert?: string, key?: string} = { };
557560

558561
// This implies without a cryptostore these will be stored plain.
559562
if (password && this.cryptoStore) {
560563
password = this.cryptoStore.encrypt(password);
561564
}
565+
566+
562567
if (config.certificate && this.cryptoStore) {
563-
cert.cert = this.cryptoStore.encrypt(config.certificate.cert);
564-
cert.key = this.cryptoStore.encrypt(config.certificate.key);
568+
keypair.cert = config.certificate.cert;
569+
const cryptoParts = [];
570+
let key = config.certificate.key;
571+
// We can't store these as our encryption system doesn't support spaces.
572+
key = key.replace('-----BEGIN PRIVATE KEY-----\n', '').replace('-----END PRIVATE KEY-----\n', '');
573+
while (key.length > 0) {
574+
const part = key.slice(0, 64);
575+
console.log(part);
576+
cryptoParts.push(this.cryptoStore.encrypt(part));
577+
key = key.slice(64);
578+
}
579+
keypair.key = cryptoParts.join(',');
580+
console.log(cryptoParts);
565581
}
566582
const parameters = {
567583
user_id: userId,
568584
domain: config.getDomain(),
569585
// either use the decrypted password, or whatever is stored already.
570586
password,
571-
cert: cert?.cert,
572-
key: cert?.key,
587+
cert: keypair.cert,
588+
key: keypair.key,
573589
config: JSON.stringify(config.serialize(true)),
574590
};
575591
const statement = PgDataStore.BuildUpsertStatement(

src/irc/ConnectionInstance.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,8 +422,6 @@ export class ConnectionInstance {
422422
saslType = "EXTERNAL";
423423
}
424424

425-
console.log("secure:", secure, saslType);
426-
427425
const connectionOpts: IrcClientOpts = {
428426
userName: opts.username,
429427
realName: opts.realname,
@@ -439,12 +437,15 @@ export class ConnectionInstance {
439437
retryCount: 0,
440438
family: (server.getIpv6Prefix() || server.getIpv6Only() ? 6 : null) as 6|null,
441439
bustRfc3484: true,
442-
sasl: opts.password ? server.useSasl() : false,
440+
sasl: saslType ? server.useSasl() : false,
443441
saslType: saslType,
444442
secure: secure,
445443
encodingFallback: opts.encodingFallback,
444+
debug: true,
446445
};
447446

447+
console.log(connectionOpts);
448+
448449

449450
// Returns: A promise which resolves to a ConnectionInstance
450451
const retryConnection = async () => {

src/models/IrcClientConfig.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ limitations under the License.
1616

1717
import { MatrixUser } from "matrix-appservice-bridge";
1818

19+
export interface IrcClientCertKeypair {
20+
cert: string;
21+
key: string;
22+
}
23+
1924
export interface IrcClientConfigSeralized {
2025
username?: string;
2126
password?: string;
22-
certificate?: {
23-
key: string;
24-
cert: string;
25-
};
27+
certificate?: IrcClientCertKeypair;
2628
nick?: string;
2729
ipv6?: string;
2830
}
@@ -70,11 +72,11 @@ export class IrcClientConfig {
7072
return this.config.password;
7173
}
7274

73-
public setCertificate(certificate: {cert: string, key: string}) {
74-
this.config.certificate = certificate;
75+
public setCertificate(keypair: IrcClientCertKeypair) {
76+
this.config.certificate = keypair;
7577
}
7678

77-
public get certificate(): {cert: string, key: string}|undefined {
79+
public get certificate(): IrcClientCertKeypair|undefined {
7880
return this.config.certificate;
7981
}
8082

@@ -94,10 +96,11 @@ export class IrcClientConfig {
9496
return this.config.ipv6;
9597
}
9698

97-
public serialize(removePassword = false) {
99+
public serialize(removePassword = false): IrcClientConfigSeralized {
98100
if (removePassword) {
99-
const clone = JSON.parse(JSON.stringify(this.config));
101+
const clone: IrcClientConfigSeralized = JSON.parse(JSON.stringify(this.config));
100102
delete clone.password;
103+
delete clone.certificate;
101104
return clone;
102105
}
103106
return this.config;

0 commit comments

Comments
 (0)