From fa54b2eec482a8eb19525dd5a89c399c05d7d9d2 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 10 Jun 2023 22:46:24 +0000 Subject: [PATCH] generate tls certs --- README.md | 5 + contrib/cfssl.sh | 4 + contrib/systemd/cfssl.service | 3 + copyparty/__main__.py | 65 +++------- copyparty/cert.py | 220 ++++++++++++++++++++++++++++++++++ copyparty/tcpsrv.py | 2 + scripts/sfx.ls | 1 + 7 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 copyparty/cert.py diff --git a/README.md b/README.md index f48978c1..4d522451 100644 --- a/README.md +++ b/README.md @@ -1572,6 +1572,11 @@ both HTTP and HTTPS are accepted by default, but letting a [reverse proxy](#rev copyparty doesn't speak HTTP/2 or QUIC, so using a reverse proxy would solve that as well +if [cfssl](https://github.com/cloudflare/cfssl/releases/latest) is installed, copyparty will automatically create a CA and server-cert on startup +* the certs are written to `--crt-dir` for distribution, see `--help` for the other `--crt` options +* this will be a self-signed certificate so you must install your `ca.pem` into all your browsers/devices +* if you want to avoid the hassle of distributing certs manually, please consider using a reverse proxy + # recovering from crashes diff --git a/contrib/cfssl.sh b/contrib/cfssl.sh index 6620f821..06381f53 100755 --- a/contrib/cfssl.sh +++ b/contrib/cfssl.sh @@ -3,6 +3,10 @@ set -e cat >/dev/null <<'EOF' +NOTE: copyparty is now able to do this automatically; +however you may wish to use this script instead if +you have specific needs (or if copyparty breaks) + this script generates a new self-signed TLS certificate and replaces the default insecure one that comes with copyparty diff --git a/contrib/systemd/cfssl.service b/contrib/systemd/cfssl.service index 235b6656..8bacdffb 100644 --- a/contrib/systemd/cfssl.service +++ b/contrib/systemd/cfssl.service @@ -1,3 +1,6 @@ +# NOTE: this is now a built-in feature in copyparty +# but you may still want this if you have specific needs +# # systemd service which generates a new TLS certificate on each boot, # that way the one-year expiry time won't cause any issues -- # just have everyone trust the ca.pem once every 10 years diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 02c2d0ff..a0ca9fc5 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -10,11 +10,9 @@ __url__ = "https://github.com/9001/copyparty/" import argparse import base64 -import filecmp import locale import os import re -import shutil import socket import sys import threading @@ -279,48 +277,6 @@ def ensure_webdeps() -> None: ) -def ensure_cert(al: argparse.Namespace) -> None: - """ - the default cert (and the entire TLS support) is only here to enable the - crypto.subtle javascript API, which is necessary due to the webkit guys - being massive memers (https://www.chromium.org/blink/webcrypto) - - i feel awful about this and so should they - """ - cert_insec = os.path.join(E.mod, "res/insecure.pem") - cert_appdata = os.path.join(E.cfg, "cert.pem") - if not os.path.isfile(al.cert): - if cert_appdata != al.cert: - raise Exception("certificate file does not exist: " + al.cert) - - shutil.copy(cert_insec, al.cert) - - with open(al.cert, "rb") as f: - buf = f.read() - o1 = buf.find(b" PRIVATE KEY-") - o2 = buf.find(b" CERTIFICATE-") - m = "unsupported certificate format: " - if o1 < 0: - raise Exception(m + "no private key inside pem") - if o2 < 0: - raise Exception(m + "no server certificate inside pem") - if o1 > o2: - raise Exception(m + "private key must appear before server certificate") - - try: - if filecmp.cmp(al.cert, cert_insec): - lprint( - "\033[33musing default TLS certificate; https will be insecure -- please see\n" - + "https://github.com/9001/copyparty/blob/hovudstraum/contrib/cfssl.sh" - + "\033[36m\ncertificate location: {}\033[0m\n".format(al.cert) - ) - except: - pass - - # speaking of the default cert, - # printf 'NO\n.\n.\n.\n.\ncopyparty-insecure\n.\n' | faketime '2000-01-01 00:00:00' openssl req -x509 -sha256 -newkey rsa:2048 -keyout insecure.pem -out insecure.pem -days $((($(printf %d 0x7fffffff)-$(date +%s --date=2000-01-01T00:00:00Z))/(60*60*24))) -nodes && ls -al insecure.pem && openssl x509 -in insecure.pem -text -noout - - def configure_ssl_ver(al: argparse.Namespace) -> None: def terse_sslver(txt: str) -> str: txt = txt.lower() @@ -748,6 +704,23 @@ def add_tls(ap, cert_path): ap2.add_argument("--ssl-log", metavar="PATH", type=u, help="log master secrets for later decryption in wireshark") +def add_cert(ap, cert_path): + cert_dir = os.path.dirname(cert_path) + ap2 = ap.add_argument_group('TLS certificate generator options') + ap2.add_argument("--no-crt", action="store_true", help="disable automatic certificate creation") + ap2.add_argument("--crt-ns", metavar="N,N", type=u, default="", help="comma-separated list of FQDNs (domains) to add into the certificate") + ap2.add_argument("--crt-exact", action="store_true", help="do not add wildcard entries for each --crt-ns") + ap2.add_argument("--crt-noip", action="store_true", help="do not add autodetected IP addresses into cert") + ap2.add_argument("--crt-dir", metavar="PATH", default=cert_dir, help="where to save the CA cert") + ap2.add_argument("--crt-cdays", metavar="D", type=float, default=3650, help="ca-certificate expiration time in days") + ap2.add_argument("--crt-sdays", metavar="D", type=float, default=365, help="server-cert expiration time in days") + ap2.add_argument("--crt-cn", metavar="TXT", type=u, default="partyco", help="CA/server-cert common-name") + ap2.add_argument("--crt-cnc", metavar="TXT", type=u, default="--crt-cn ca", help="override CA name") + ap2.add_argument("--crt-cns", metavar="TXT", type=u, default="--crt-cn cpp", help="override server-cert name") + ap2.add_argument("--crt-back", metavar="HRS", type=float, default=72, help="backdate in hours") + ap2.add_argument("--crt-alg", metavar="S-N", type=u, default="ecdsa-256", help="algorithm and keysize; one of these: ecdsa-256 rsa-4096 rsa-2048") + + def add_zeroconf(ap): ap2 = ap.add_argument_group("Zeroconf options") ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)") @@ -1051,6 +1024,7 @@ def run_argparse( add_general(ap, nc, srvname) add_network(ap) add_tls(ap, cert_path) + add_cert(ap, cert_path) add_qr(ap, tty) add_zeroconf(ap) add_zc_mdns(ap) @@ -1208,9 +1182,6 @@ def main(argv: Optional[list[str]] = None) -> None: elif not al.no_ansi: al.ansi = VT100 - if HAVE_SSL: - ensure_cert(al) - if WINDOWS and not al.keep_qem: try: disable_quickedit() diff --git a/copyparty/cert.py b/copyparty/cert.py new file mode 100644 index 00000000..cb0f2b9b --- /dev/null +++ b/copyparty/cert.py @@ -0,0 +1,220 @@ +import os +import errno +import time +import json +import shutil +import filecmp +import calendar + +from .util import runcmd, Netdev + + +try: + HAVE_SSL = True + import ssl +except: + HAVE_SSL = False + + +HAVE_CFSSL = True + + +def ensure_cert(log: "RootLogger", args) -> None: + """ + the default cert (and the entire TLS support) is only here to enable the + crypto.subtle javascript API, which is necessary due to the webkit guys + being massive memers (https://www.chromium.org/blink/webcrypto) + + i feel awful about this and so should they + """ + cert_insec = os.path.join(args.E.mod, "res/insecure.pem") + cert_appdata = os.path.join(args.E.cfg, "cert.pem") + if not os.path.isfile(args.cert): + if cert_appdata != args.cert: + raise Exception("certificate file does not exist: " + args.cert) + + shutil.copy(cert_insec, args.cert) + + with open(args.cert, "rb") as f: + buf = f.read() + o1 = buf.find(b" PRIVATE KEY-") + o2 = buf.find(b" CERTIFICATE-") + m = "unsupported certificate format: " + if o1 < 0: + raise Exception(m + "no private key inside pem") + if o2 < 0: + raise Exception(m + "no server certificate inside pem") + if o1 > o2: + raise Exception(m + "private key must appear before server certificate") + + try: + if filecmp.cmp(args.cert, cert_insec): + t = "using default TLS certificate; https will be insecure:\033[36m {}" + log("cert", t.format(args.cert), 3) + except: + pass + + # speaking of the default cert, + # printf 'NO\n.\n.\n.\n.\ncopyparty-insecure\n.\n' | faketime '2000-01-01 00:00:00' openssl req -x509 -sha256 -newkey rsa:2048 -keyout insecure.pem -out insecure.pem -days $((($(printf %d 0x7fffffff)-$(date +%s --date=2000-01-01T00:00:00Z))/(60*60*24))) -nodes && ls -al insecure.pem && openssl x509 -in insecure.pem -text -noout + + +def _read_crt(args, fn): + try: + if not os.path.exists(os.path.join(args.crt_dir, fn)): + return 0, {} + + acmd = ["cfssl-certinfo", "-cert", fn] + rc, so, se = runcmd(acmd, cwd=args.crt_dir) + if rc: + return 0, {} + + inf = json.loads(so) + zs = inf["not_after"] + expiry = calendar.timegm(time.strptime(zs, "%Y-%m-%dT%H:%M:%SZ")) + return expiry, inf + except OSError as ex: + if ex.errno == errno.ENOENT: + raise + return 0, {} + except: + return 0, {} + + +def _gen_ca(log: "RootLogger", args): + expiry = _read_crt(args, "ca.pem")[0] + if time.time() + args.crt_cdays * 60 * 60 * 24 * 0.1 < expiry: + return + + backdate = "{}m".format(int(args.crt_back * 60)) + expiry = "{}m".format(int(args.crt_cdays * 60 * 24)) + cn = args.crt_cnc.replace("--crt-cn", args.crt_cn) + algo, ksz = args.crt_alg.split("-") + req = { + "CN": cn, + "CA": {"backdate": backdate, "expiry": expiry, "pathlen": 0}, + "key": {"algo": algo, "size": int(ksz)}, + "names": [{"O": cn}], + } + sin = json.dumps(req).encode("utf-8") + log("cert", "creating new ca ...", 6) + + cmd = "cfssl gencert -initca -" + rc, so, se = runcmd(cmd.split(), 30, sin=sin) + if rc: + raise Exception("failed to create ca-cert: {}, {}".format(rc, se), 3) + + cmd = "cfssljson -bare ca" + sin = so.encode("utf-8") + rc, so, se = runcmd(cmd.split(), 10, sin=sin, cwd=args.crt_dir) + if rc: + raise Exception("failed to translate ca-cert: {}, {}".format(rc, se), 3) + + bname = os.path.join(args.crt_dir, "ca") + os.rename(bname + "-key.pem", bname + ".key") + os.unlink(bname + ".csr") + + log("cert", "new ca OK", 2) + + +def _gen_srv(log: "RootLogger", args, netdevs: dict[str, Netdev]): + names = args.crt_ns.split(",") if args.crt_ns else [] + if not args.crt_exact: + for n in names[:]: + names.append("*.{}".format(n)) + if not args.crt_noip: + for ip in netdevs.keys(): + names.append(ip.split("/")[0]) + if not names: + names = ["127.0.0.1"] + names = list({x: 1 for x in names}.keys()) + + try: + expiry, inf = _read_crt(args, "srv.pem") + expired = time.time() + args.crt_sdays * 60 * 60 * 24 * 0.1 > expiry + cert_insec = os.path.join(args.E.mod, "res/insecure.pem") + for n in names: + if n not in inf["sans"]: + raise Exception("does not have {}".format(n)) + if expired: + raise Exception("old server-cert has expired") + if not filecmp.cmp(args.cert, cert_insec): + return + except Exception as ex: + log("cert", "will create new server-cert; {}".format(ex)) + + log("cert", "creating server-cert ...", 6) + + backdate = "{}m".format(int(args.crt_back * 60)) + expiry = "{}m".format(int(args.crt_sdays * 60 * 24)) + cfg = { + "signing": { + "default": { + "backdate": backdate, + "expiry": expiry, + "usages": ["signing", "key encipherment", "server auth"], + } + } + } + with open(os.path.join(args.crt_dir, "cfssl.json"), "wb") as f: + f.write(json.dumps(cfg).encode("utf-8")) + + cn = args.crt_cnc.replace("--crt-cn", args.crt_cn) + algo, ksz = args.crt_alg.split("-") + req = { + "key": {"algo": algo, "size": int(ksz)}, + "names": [{"O": cn}], + } + sin = json.dumps(req).encode("utf-8") + + cmd = "cfssl gencert -config=cfssl.json -ca ca.pem -ca-key ca.key -profile=www" + acmd = cmd.split() + ["-hostname=" + ",".join(names), "-"] + rc, so, se = runcmd(acmd, 30, sin=sin, cwd=args.crt_dir) + if rc: + raise Exception("failed to create cert: {}, {}".format(rc, se)) + + cmd = "cfssljson -bare srv" + sin = so.encode("utf-8") + rc, so, se = runcmd(cmd.split(), 10, sin=sin, cwd=args.crt_dir) + if rc: + raise Exception("failed to translate cert: {}, {}".format(rc, se)) + + bname = os.path.join(args.crt_dir, "srv") + os.rename(bname + "-key.pem", bname + ".key") + os.unlink(bname + ".csr") + + with open(os.path.join(args.crt_dir, "ca.pem"), "rb") as f: + ca = f.read() + + with open(bname + ".key", "rb") as f: + skey = f.read() + + with open(bname + ".pem", "rb") as f: + scrt = f.read() + + with open(args.cert, "wb") as f: + f.write(skey + scrt + ca) + + log("cert", "new server-cert OK", 2) + + +def gencert(log: "RootLogger", args, netdevs: dict[str, Netdev]): + global HAVE_CFSSL + + if not HAVE_SSL or args.http_only: + return + + if args.no_crt or not HAVE_CFSSL: + ensure_cert(log, args) + return + + try: + _gen_ca(log, args) + _gen_srv(log, args, netdevs) + except Exception as ex: + HAVE_CFSSL = False + log("cert", "could not create TLS certificates: {}".format(ex), 3) + if getattr(ex, "errno", 0) == errno.ENOENT: + t = "install cfssl if you want to fix this; https://github.com/cloudflare/cfssl/releases/latest" + log("cert", t, 6) + + ensure_cert(log, args) diff --git a/copyparty/tcpsrv.py b/copyparty/tcpsrv.py index 0cf81830..2077d116 100644 --- a/copyparty/tcpsrv.py +++ b/copyparty/tcpsrv.py @@ -9,6 +9,7 @@ import time from .__init__ import ANYWIN, PY2, TYPE_CHECKING, unicode from .stolen.qrcodegen import QrCode +from .cert import gencert from .util import ( E_ACCESS, E_ADDR_IN_USE, @@ -295,6 +296,7 @@ class TcpSrv(object): def _distribute_netdevs(self): self.hub.broker.say("set_netdevs", self.netdevs) self.hub.start_zeroconf() + gencert(self.log, self.args, self.netdevs) def shutdown(self) -> None: self.stopping = True diff --git a/scripts/sfx.ls b/scripts/sfx.ls index c7373235..b3a96964 100644 --- a/scripts/sfx.ls +++ b/scripts/sfx.ls @@ -11,6 +11,7 @@ copyparty/broker_mp.py, copyparty/broker_mpw.py, copyparty/broker_thr.py, copyparty/broker_util.py, +copyparty/cert.py, copyparty/cfg.py, copyparty/dxml.py, copyparty/fsutil.py,