diff --git a/package.json b/package.json index c1969700..6e58fecf 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "linkify-it": "2.2.0", "lodash": "4.17.15", "mime-types": "2.1.26", + "node-forge": "0.9.1", "package-json": "6.5.0", "read": "1.0.7", "read-chunk": "3.2.0", diff --git a/src/helper.js b/src/helper.js index d68bdef8..2b691052 100644 --- a/src/helper.js +++ b/src/helper.js @@ -18,6 +18,7 @@ let storagePath; let packagesPath; let fileUploadPath; let userLogsPath; +let clientCertificatesPath; const Helper = { config: null, @@ -31,6 +32,7 @@ const Helper = { getUsersPath, getUserConfigPath, getUserLogsPath, + getClientCertificatesPath, setHome, getVersion, getVersionCacheBust, @@ -100,6 +102,7 @@ function setHome(newPath) { fileUploadPath = path.join(homePath, "uploads"); packagesPath = path.join(homePath, "packages"); userLogsPath = path.join(homePath, "logs"); + clientCertificatesPath = path.join(homePath, "certificates"); // Reload config from new home location if (fs.existsSync(configPath)) { @@ -185,6 +188,10 @@ function getUserLogsPath() { return userLogsPath; } +function getClientCertificatesPath() { + return clientCertificatesPath; +} + function getStoragePath() { return storagePath; } diff --git a/src/models/network.js b/src/models/network.js index 9fe4ce33..dad647ef 100644 --- a/src/models/network.js +++ b/src/models/network.js @@ -7,6 +7,7 @@ const Chan = require("./chan"); const Msg = require("./msg"); const Helper = require("../helper"); const STSPolicies = require("../plugins/sts"); +const ClientCertificate = require("../plugins/clientCertificate"); module.exports = Network; @@ -86,6 +87,10 @@ Network.prototype.validate = function (client) { this.port = this.tls ? 6697 : 6667; } + if (!this.tls) { + ClientCertificate.remove(this.uuid); + } + if (Helper.config.lockNetwork) { // This check is needed to prevent invalid user configurations if ( @@ -182,6 +187,14 @@ Network.prototype.setIrcFrameworkOptions = function (client) { this.irc.options.tls = this.tls; this.irc.options.rejectUnauthorized = this.rejectUnauthorized; this.irc.options.webirc = this.createWebIrc(client); + + this.irc.options.client_certificate = this.tls ? ClientCertificate.get(this.uuid) : null; + + if (this.irc.options.client_certificate && !this.irc.options.password) { + this.irc.options.sasl_mechanism = "EXTERNAL"; + } else { + delete this.irc.options.sasl_mechanism; + } }; Network.prototype.createWebIrc = function (client) { diff --git a/src/plugins/clientCertificate.js b/src/plugins/clientCertificate.js new file mode 100644 index 00000000..071924c5 --- /dev/null +++ b/src/plugins/clientCertificate.js @@ -0,0 +1,134 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const {md, pki} = require("node-forge"); +const log = require("../log"); +const Helper = require("../helper"); + +module.exports = { + get, + remove, +}; + +function get(uuid) { + if (Helper.config.public) { + return null; + } + + const folderPath = Helper.getClientCertificatesPath(); + const paths = getPaths(folderPath, uuid); + + if (!fs.existsSync(paths.privateKeyPath) || !fs.existsSync(paths.certificatePath)) { + return generateAndWrite(folderPath, paths); + } + + try { + return { + private_key: fs.readFileSync(paths.privateKeyPath, "utf-8"), + certificate: fs.readFileSync(paths.certificatePath, "utf-8"), + }; + } catch (e) { + log.error("Unable to remove certificate", e); + } + + return null; +} + +function remove(uuid) { + if (Helper.config.public) { + return null; + } + + const paths = getPaths(Helper.getClientCertificatesPath(), uuid); + + try { + if (fs.existsSync(paths.privateKeyPath)) { + fs.unlinkSync(paths.privateKeyPath); + } + + if (fs.existsSync(paths.certificatePath)) { + fs.unlinkSync(paths.certificatePath); + } + } catch (e) { + log.error("Unable to remove certificate", e); + } +} + +function generateAndWrite(folderPath, paths) { + const certificate = generate(); + + try { + fs.mkdirSync(folderPath, {recursive: true}); + + fs.writeFileSync(paths.privateKeyPath, certificate.private_key, { + mode: 0o600, + }); + fs.writeFileSync(paths.certificatePath, certificate.certificate, { + mode: 0o600, + }); + + return certificate; + } catch (e) { + log.error("Unable to write certificate", e); + } + + return null; +} + +function generate() { + const keys = pki.rsa.generateKeyPair(2048); + const cert = pki.createCertificate(); + + cert.publicKey = keys.publicKey; + cert.serialNumber = crypto.randomBytes(16).toString("hex").toUpperCase(); + + // Set notBefore a day earlier just in case the time between + // the client and server is not perfectly in sync + cert.validity.notBefore = new Date(); + cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1); + + // Set notAfter 100 years into the future just in case + // the server actually validates this field + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 100); + + const attrs = [ + { + name: "commonName", + value: "The Lounge IRC Client", + }, + ]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + + // Set extensions that indicate this is a client authentication certificate + cert.setExtensions([ + { + name: "extKeyUsage", + clientAuth: true, + }, + { + name: "nsCertType", + client: true, + }, + ]); + + // Sign this certificate with a SHA256 signature + cert.sign(keys.privateKey, md.sha256.create()); + + const pem = { + private_key: pki.privateKeyToPem(keys.privateKey), + certificate: pki.certificateToPem(cert), + }; + + return pem; +} + +function getPaths(folderPath, uuid) { + return { + privateKeyPath: path.join(folderPath, `${uuid}.pem`), + certificatePath: path.join(folderPath, `${uuid}.crt`), + }; +} diff --git a/src/plugins/inputs/quit.js b/src/plugins/inputs/quit.js index e9f6f9ff..0d10ced1 100644 --- a/src/plugins/inputs/quit.js +++ b/src/plugins/inputs/quit.js @@ -1,6 +1,7 @@ "use strict"; const _ = require("lodash"); +const ClientCertificate = require("../clientCertificate"); exports.commands = ["quit"]; exports.allowDisconnected = true; @@ -18,5 +19,7 @@ exports.input = function (network, chan, cmd, args) { const quitMessage = args[0] ? args.join(" ") : null; network.quit(quitMessage); + ClientCertificate.remove(network.uuid); + return true; }; diff --git a/test/fixtures/.gitignore b/test/fixtures/.gitignore index 7f5712ec..f6cb85dc 100644 --- a/test/fixtures/.gitignore +++ b/test/fixtures/.gitignore @@ -1,6 +1,7 @@ # Files that may be generated by tests .thelounge/storage/ .thelounge/logs/ +.thelounge/certificates/ # Fixtures contain fake packages, stored in a fake node_modules folder !.thelounge/packages/node_modules/ diff --git a/test/plugins/clientCertificate.js b/test/plugins/clientCertificate.js new file mode 100644 index 00000000..6330b62e --- /dev/null +++ b/test/plugins/clientCertificate.js @@ -0,0 +1,53 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const {expect} = require("chai"); +const ClientCertificate = require("../../src/plugins/clientCertificate"); +const Helper = require("../../src/helper"); + +describe("ClientCertificate", function () { + it("should not generate a client certificate in public mode", function () { + Helper.config.public = true; + + const certificate = ClientCertificate.get("this-is-test-uuid"); + expect(certificate).to.be.null; + }); + + it("should generate a client certificate", function () { + Helper.config.public = false; + const certificate = ClientCertificate.get("this-is-test-uuid"); + + expect(certificate.certificate).to.match(/^-----BEGIN CERTIFICATE-----/); + expect(certificate.private_key).to.match(/^-----BEGIN RSA PRIVATE KEY-----/); + + const certificate2 = ClientCertificate.get("this-is-test-uuid"); + expect(certificate2.certificate).to.equal(certificate.certificate); + expect(certificate2.private_key).to.equal(certificate.private_key); + + Helper.config.public = true; + }); + + it("should remove the client certificate files", function () { + Helper.config.public = false; + + const privateKeyPath = path.join( + Helper.getClientCertificatesPath(), + `this-is-test-uuid.pem` + ); + const certificatePath = path.join( + Helper.getClientCertificatesPath(), + `this-is-test-uuid.crt` + ); + + expect(fs.existsSync(privateKeyPath)).to.be.true; + expect(fs.existsSync(certificatePath)).to.be.true; + + ClientCertificate.remove("this-is-test-uuid"); + + expect(fs.existsSync(privateKeyPath)).to.be.false; + expect(fs.existsSync(certificatePath)).to.be.false; + + Helper.config.public = true; + }); +}); diff --git a/yarn.lock b/yarn.lock index 297bf7ef..e8333069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5853,6 +5853,11 @@ node-fetch@2.1.2: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5" integrity sha1-q4hOjn5X44qUR1POxwb3iNF2i7U= +node-forge@0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" + integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== + node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425"