From d86a0c9385b7af1d3fd11f0e908799411b005333 Mon Sep 17 00:00:00 2001 From: deuce <> Date: Fri, 23 Feb 2018 23:08:07 +0000 Subject: [PATCH] LetSyncrypt.js -- an AJAXv2 client for Let's Encrypt. This script will request and install a certificate, then recycle your web server. This is barely sufficient, but a lot more needs to be done... 1) Tracking certificate expiration, and only placing a new order when appropriate. 2) Handling failure better. 3) Handle changes in the system password (like anyone ever does THAT). 4) Clean up stale authorizations. Also, some enhanced features would be nice: 1) Adding a bunch of SANs, so virtual hosts Just Work 2) Key aging and updating 3) More control of certificate contents... I can't find a list of what Let's Encrypt supports in CSRs. --- exec/letsyncrypt.js | 187 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 exec/letsyncrypt.js diff --git a/exec/letsyncrypt.js b/exec/letsyncrypt.js new file mode 100644 index 0000000000..018c2ea702 --- /dev/null +++ b/exec/letsyncrypt.js @@ -0,0 +1,187 @@ +require("acmev2.js", "ACMEv2"); + +/* + * File names used... + */ +var ks_fname = backslash(system.ctrl_dir)+"letsyncrypt.key"; +var setting_fname = backslash(system.ctrl_dir)+"letsyncrypt.ini"; +var sks_fname = backslash(system.ctrl_dir)+"ssl.cert"; +var sbbsini_fname = backslash(system.ctrl_dir)+"sbbs.ini"; +var maincnf_fname = backslash(system.ctrl_dir)+"main.cnf"; +var recycle_sem = backslash(system.ctrl_dir)+"recycle.web"; + +/* + * Get the Web Root + */ +var sbbsini = new File(sbbsini_fname); +if (!sbbsini.open("r")) + throw("Unable to open "+sbbsini.name); +var webroot = backslash(sbbsini.iniGetValue("Web", "RootDirectory", "../web/root")); +sbbsini.close(); + +/* + * Now read in the system password which must be used to encrypt the + * private key. + * + * TODO: What happens when the system password changes? + */ +var maincnf = new File(maincnf_fname); +if (!maincnf.open("rb", true)) + throw("Unable to open "+maincnf.name); +maincnf.position = 186; // Indeed. +var syspass = maincnf.read(40); +syspass = syspass.replace(/\x00/g,''); +maincnf.close(); + +/* + * Now open/create the keyset and RSA signing key for + * ACME. Note that this key is not the one used for the + * final certificate. + */ +var opts = CryptKeyset.KEYOPT.NONE; +if (!file_exists(ks_fname)) + opts = CryptKeyset.KEYOPT.CREATE; +var ks = new CryptKeyset(ks_fname, opts); + +/* + * The ACME key uses "ACMEv2" as the label. + * + * TODO: Regenerate keys etc. + */ +var rsa; +try { + rsa = ks.get_private_key("ACMEv2", syspass); +} +catch(e) { + rsa = new CryptContext(CryptContext.ALGO.RSA); + rsa.keysize=2048/8; + rsa.label="ACMEv2"; + rsa.generate_key(); + ks.add_private_key(rsa, syspass); +} + +/* + * We store the key ID in our ini file so we don't need an extra + * round-trip each session to discover it. + */ +var settings = new File(setting_fname); +settings.open(settings.exists ? "r+" : "w+"); +var key_id = settings.iniGetValue("key_id", system.inet_addr, undefined); +var acme = new ACMEv2({key:rsa, key_id:key_id}); +if (acme.key_id === undefined) { + acme.create_new_account({termsOfServiceAgreed:true,contact:["mailto:sysop@"+system.inet_addr]}); +} +/* + * After the ACMEv2 object is created, we will always have a key_id + * Write it to our ini if it wasn't there already. + */ +if (key_id === undefined) { + settings.iniSetValue("key_id", system.inet_addr, acme.key_id); + key_id = acme.key_id; +} +settings.close(); + +/* + * Create the order, using system.inet_addr + * + * TODO: SNAs... or something. + */ +var order = acme.create_new_order({identifiers:[{type:"dns",value:system.inet_addr}]}); +var authz; +var challenge; +var auth; + +/* + * Find an http-01 authorization + */ +for (auth in order.authorizations) { + authz = acme.get_authorization(order.authorizations[auth]); + for (challenge in authz.challenges) { + if (authz.challenges[challenge].type=='http-01') + break; + } + if (authz.challenges[challenge].type=='http-01') + break; +} +if (authz.challenges[challenge].type!='http-01') + throw("No supported challenges!"); +/* + * Create a place to store the challenge and store it there + * + * TODO: Clean up stale files + */ +mkpath(webroot+".well-known/acme-challenge"); +var token = new File(backslash(webroot+".well-known/acme-challenge")+authz.challenges[challenge].token); +token.open("w"); +token.write(authz.challenges[challenge].token+"."+acme.thumbprint()); +token.close(); + +/* + * Tell the ACMEv2 server we've created the file. + */ +var tmp = acme.accept_challenge(authz.challenges[challenge]); + +/* + * Wait for server to confirm + */ +while (!acme.poll_authorization(order.authorizations[auth])) + mswait(1000); + +/* + * Create CSR + * + * TODO: SANs, virtual hosts, etc... + */ +var csr = new CryptCert(CryptCert.TYPE.CERTREQUEST); +// TODO: Read these from INI file? +csr.oganizationname=system.name; +csr.commonname=system.inet_addr; + +/* + * Now open/create the keyset and RSA signing key for + * Synchronet. + * + * TODO: Regenerate keys etc. + */ +var opts = CryptKeyset.KEYOPT.NONE; +if (!file_exists(sks_fname)) + opts = CryptKeyset.KEYOPT.CREATE; +var sks = new CryptKeyset(sks_fname, opts); + +var certrsa; +try { + certrsa = sks.get_private_key("ssl_cert", syspass); +} +catch(e) { + certrsa = new CryptContext(CryptContext.ALGO.RSA); + certrsa.keysize=2048/8; + certrsa.label="ssl_cert"; + certrsa.generate_key(); + sks.add_private_key(certrsa, syspass); +} +csr.subjectpublickeyinfo=certrsa; +csr.sign(certrsa); +csr.check(); +var csrenc=csr.export(CryptCert.FORMAT.TEXT_CERTIFICATE); +order = acme.finalize_order(order, csr); + +while (order.status !== 'valid') { + order = acme.poll_order(order); +} + +var cert = acme.get_cert(order); +cert.label = "ssl_cert"; + +/* + * Delete the old certificate + */ +try { + sks.delete_key(ssl_cert); +} +catch(e) {} +sks.add_public_key(cert); + +/* + * Recycle webserver + */ +file_touch(recycle_sem); -- GitLab