Generate client certificates and automatically do SASL EXTERNAL

This commit is contained in:
Pavel Djundik 2020-03-30 23:15:32 +03:00
parent 3900e9dd81
commit f8f692af05
8 changed files with 217 additions and 0 deletions

View file

@ -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",

View file

@ -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;
}

View file

@ -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) {

View file

@ -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`),
};
}

View file

@ -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;
};

View file

@ -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/

View file

@ -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;
});
});

View file

@ -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"