#!/usr/bin/env python3 # Pythonized automated way of running https://sysadministrivia.com/news/hardening-ssh-security # TODO: check for cryptography module. if it exists, we can do this entirely pythonically # without ever needing to use subprocess/ssh-keygen, i think! # Thanks to https://stackoverflow.com/a/39126754. # Also, I need to re-write this. It's getting uglier. # stdlib import datetime import glob import os import pwd import re import signal import shutil import subprocess # REMOVE WHEN SWITCHING TO PURE PYTHON #### PREP FOR PURE PYTHON IMPLEMENTATION #### # # non-stdlib - testing and automatic install if necessary. # # TODO # # - cryptography module won't generate new-format "openssh-key-v1" keys. # - See https://github.com/pts/py_ssh_keygen_ed25519 for possible conversion to python 3 # - https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key # - https://github.com/pyca/cryptography/issues/3509 and https://github.com/paramiko/paramiko/issues/1136 # has_crypto = False # pure_py = False # has_pip = False # pipver = None # try: # import cryptography # has_crypto = True # except ImportError: # # We'll try to install it. We set up the logic below. # try: # import pip # has_pip = True # # We'll use these to create a temporary lib path and remove it when done. # import sys # import tempfile # except ImportError: # # ABSOLUTE LAST fallback, if we got to THIS case, is to use subprocess. # has_pip = False # import subprocess # # # Try installing it then! # if not all((has_crypto, )): # # venv only included after python 3.3.x. We fallback to subprocess if we can't do dis. # if sys.hexversion >= 0x30300f0: # has_ensurepip = False # import venv # if not has_pip and sys.hexversion >= 0x30400f0: # import ensurepip # has_ensurepip = True # temppath = tempfile.mkdtemp('_VENV') # v = venv.create(temppath) # if has_ensurepip and not has_pip: # # This SHOULD be unnecessary, but we want to try really hard. # ensurepip.bootstrap(root = temppath) # import pip # has_pip = True # if has_pip: # pipver = pip.__version__.split('.') # # A thousand people are yelling at me for this. # if int(pipver[0]) >= 10: # from pip._internal import main as pipinstall # else: # pipinstall = pip.main # if int(pipver[0]) >= 8: # pipcmd = ['install', # '--prefix={0}'.format(temppath), # '--ignore-installed'] # else: # pipcmd = ['install', # '--install-option="--prefix={0}"'.format(temppath), # '--ignore-installed'] # # Get the lib path. # libpath = os.path.join(temppath, 'lib') # if os.path.exists('{0}64'.format(libpath)) and not os.path.islink('{0}64'.format(libpath)): # libpath += '64' # for i in os.listdir(libpath): # TODO: make this more sane. We cheat a bit here by making assumptions. # if re.search('python([0-9]+(\.[0-9]+)?)?$', i): # libpath = os.path.join(libpath, i) # break # libpath = os.path.join(libpath, 'site-packages') # sys.prefix = temppath # for m in ('cryptography', 'ed25519'): # pipinstall(['install', 'cryptography']) # sys.path.append(libpath) # try: # import cryptography # has_crypto = True # except ImportError: # All that trouble for nothin'. Shucks. # pass # # if all((has_crypto, )): # pure_py = True # # if pure_py: # from cryptography.hazmat.primitives import serialization as crypto_serialization # from cryptography.hazmat.primitives.asymmetric import rsa # from cryptography.hazmat.backends import default_backend as crypto_default_backend # # We need static backup suffixes. tstamp = int(datetime.datetime.utcnow().timestamp()) # TODO: associate various config directives with version, too. # For now, we use this for primarily CentOS 6.x, which doesn't support ED25519 and probably some of the MACs. # Bastards. # https://ssh-comparison.quendi.de/comparison/cipher.html at some point in the future... # TODO: maybe implement some parsing of the ssh -Q stuff? https://superuser.com/a/869005/984616 # If you encounter a version incompatibility, please let me know! # nmap --script ssh2-enum-algos -PN -sV -p22 magic_ver = 6.5 ssh_ver = subprocess.run(['ssh', '-V'], stderr = subprocess.PIPE).stderr.decode('utf-8').strip().split()[0] # FUCK YOU, DEBIAN. FUCK YOU AND ALL OF YOUR DERIVATIVES. YOU'RE FUCKING TRASH. # YOU BELONG NOWHERE NEAR A DATACENTER. #ssh_ver = float(re.sub('^(Open|Sun_)SSH_([0-9\.]+)(p[0-9]+)?,.*$', '\g<2>', ssh_ver)) ssh_ver = float(re.sub('^(?:Open|Sun_)SSH_([0-9\.]+)(?:p[0-9]+)?(?:,|\s+).*$', '\g<1>', ssh_ver)) if ssh_ver >= magic_ver: has_ed25519 = True supported_keys = ('ed25519', 'rsa') new_moduli = False else: has_ed25519 = False supported_keys = ('rsa', ) new_moduli = False # https://github.com/openssh/openssh-portable/commit/3e60d18fba1b502c21d64fc7e81d80bcd08a2092 if ssh_ver >= 8.1: new_moduli = True conf_options = {} conf_options['sshd'] = {'KexAlgorithms': 'diffie-hellman-group-exchange-sha256', 'Protocol': '2', 'HostKey': ['/etc/ssh/ssh_host_rsa_key'], #'PermitRootLogin': 'prohibit-password', # older daemons don't like "prohibit-..." 'PermitRootLogin': 'without-password', 'PasswordAuthentication': 'no', 'ChallengeResponseAuthentication': 'no', 'PubkeyAuthentication': 'yes', 'Ciphers': 'aes256-ctr,aes192-ctr,aes128-ctr', 'MACs': 'hmac-sha2-512,hmac-sha2-256'} if has_ed25519: conf_options['sshd']['HostKey'].append('/etc/ssh/ssh_host_ed25519_key') conf_options['sshd']['KexAlgorithms'] = ','.join(('curve25519-sha256@libssh.org', conf_options['sshd']['KexAlgorithms'])) conf_options['sshd']['Ciphers'] = ','.join((('chacha20-poly1305@openssh.com,' 'aes256-gcm@openssh.com,' 'aes128-gcm@openssh.com'), conf_options['sshd']['Ciphers'])) conf_options['sshd']['MACs'] = ','.join((('hmac-sha2-512-etm@openssh.com,' 'hmac-sha2-256-etm@openssh.com,' 'umac-128-etm@openssh.com'), conf_options['sshd']['MACs'], 'umac-128@openssh.com')) # Uncomment if this is further configured #conf_options['sshd']['AllowGroups'] = 'ssh-user' conf_options['ssh'] = {'Host': {'*': {'KexAlgorithms': 'diffie-hellman-group-exchange-sha256', 'PubkeyAuthentication': 'yes', 'HostKeyAlgorithms': 'ssh-rsa'}}} if has_ed25519: conf_options['ssh']['Host']['*']['KexAlgorithms'] = ','.join(('curve25519-sha256@libssh.org', conf_options['ssh']['Host']['*']['KexAlgorithms'])) conf_options['ssh']['Host']['*']['HostKeyAlgorithms'] = ','.join( (('ssh-ed25519-cert-v01@openssh.com,' 'ssh-rsa-cert-v01@openssh.com,' 'ssh-ed25519'), conf_options['ssh']['Host']['*']['HostKeyAlgorithms'])) def hostKeys(buildmoduli): # Starting haveged should help lessen the time load a non-negligible amount, especially on virtual platforms. if os.path.lexists('/usr/bin/haveged'): # We could use psutil here, but then that's a python dependency we don't need. # We could parse the /proc directory, but that's quite unnecessary. pgrep's installed by default on # most distros. with open(os.devnull, 'wb') as devnull: if subprocess.run(['pgrep', 'haveged'], stdout = devnull).returncode != 0: subprocess.run(['haveged'], stdout = devnull) #Warning: The moduli stuff takes a LONG time to run. Hours. if buildmoduli: if not new_moduli: subprocess.run(['ssh-keygen', '-G', '/etc/ssh/moduli.all', '-b', '4096', '-q']) subprocess.run(['ssh-keygen', '-T', '/etc/ssh/moduli.safe', '-f', '/etc/ssh/moduli.all', '-q']) else: subprocess.run(['ssh-keygen', '-q', '-M', 'generate', '-O', 'bits=4096', '/etc/ssh/moduli.all']) subprocess.run(['ssh-keygen', '-q', '-M', 'screen', '-f', '/etc/ssh/moduli.all', '/etc/ssh/moduli.safe']) if os.path.lexists('/etc/ssh/moduli'): os.rename('/etc/ssh/moduli', '/etc/ssh/moduli.old') os.rename('/etc/ssh/moduli.safe', '/etc/ssh/moduli') os.remove('/etc/ssh/moduli.all') for suffix in ('', '.pub'): for k in glob.glob('/etc/ssh/ssh_host_*key{0}'.format(suffix)): os.rename(k, '{0}.old.{1}'.format(k, tstamp)) if has_ed25519: subprocess.run(['ssh-keygen', '-t', 'ed25519', '-f', '/etc/ssh/ssh_host_ed25519_key', '-q', '-N', '']) subprocess.run(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-f', '/etc/ssh/ssh_host_rsa_key', '-q', '-N', '']) # We currently don't use this, but for simplicity's sake let's return the host keys. hostkeys = {} for k in supported_keys: with open('/etc/ssh/ssh_host_{0}_key.pub'.format(k), 'r') as f: hostkeys[k] = f.read() return(hostkeys) def config(opts, t): special = {'sshd': {}, 'ssh': {}} # We need to handle these directives a little differently... special['sshd']['opts'] = ['Match'] special['sshd']['filters'] = ['User', 'Group', 'Host', 'LocalAddress', 'LocalPort', 'Address'] # These are arguments supported by each of the special options. We'll use this to verify entries. special['sshd']['args'] = ['AcceptEnv', 'AllowAgentForwarding', 'AllowGroups', 'AllowStreamLocalForwarding', 'AllowTcpForwarding', 'AllowUsers', 'AuthenticationMethods', 'AuthorizedKeysCommand', 'AuthorizedKeysCommandUser', 'AuthorizedKeysFile', 'AuthorizedPrincipalsCommand', 'AuthorizedPrincipalsCommandUser', 'AuthorizedPrincipalsFile', 'Banner', 'ChrootDirectory', 'ClientAliveCountMax', 'ClientAliveInterval', 'DenyGroups', 'DenyUsers', 'ForceCommand', 'GatewayPorts', 'GSSAPIAuthentication', 'HostbasedAcceptedKeyTypes', 'HostbasedAuthentication', 'HostbasedUsesNameFromPacketOnly', 'IPQoS', 'KbdInteractiveAuthentication', 'KerberosAuthentication', 'MaxAuthTries', 'MaxSessions', 'PasswordAuthentication', 'PermitEmptyPasswords', 'PermitOpen', 'PermitRootLogin', 'PermitTTY', 'PermitTunnel', 'PermitUserRC', 'PubkeyAcceptedKeyTypes', 'PubkeyAuthentication', 'RekeyLimit', 'RevokedKeys', 'StreamLocalBindMask', 'StreamLocalBindUnlink', 'TrustedUserCAKeys', 'X11DisplayOffset', 'X11Forwarding', 'X11UseLocalHost'] special['ssh']['opts'] = ['Host', 'Match'] special['ssh']['args'] = ['canonical', 'exec', 'host', 'originalhost', 'user', 'localuser'] cf = '/etc/ssh/{0}_config'.format(t) shutil.copy2(cf, '{0}.bak.{1}'.format(cf, tstamp)) with open(cf, 'r') as f: conf = f.readlines() conf.append('\n\n# Added per https://sysadministrivia.com/news/hardening-ssh-security\n\n') confopts = [] # Get an index of directives pre-existing in the config file. for line in conf[:]: opt = line.split() if opt: if not re.match('^(#.*|\s+.*)$', opt[0]): confopts.append(opt[0]) # We also need to modify the config file- comment out starting with the first occurrence of the # specopts, if it exists. This is why we make a backup. commentidx = None for idx, i in enumerate(conf): if re.match('^({0})\s+.*$'.format('|'.join(special[t]['opts'])), i): commentidx = idx break if commentidx is not None: idx = commentidx while idx <= (len(conf) - 1): conf[idx] = '#{0}'.format(conf[idx]) idx += 1 # Now we actually start replacing/adding some major configuration. for o in opts.keys(): if o in special[t]['opts'] or isinstance(opts[o], dict): # We need to put these at the bottom of the file due to how they're handled by sshd's config parsing. continue # We handle these a little specially too- they're for multiple lines sharing the same directive. # Since the config should be explicit, we remove any existing entries specified that we find. else: if o in confopts: # If I was more worried about recursion, or if I was appending here, I should use conf[:]. # But I'm not. So I won't. for idx, opt in enumerate(conf): if re.match('^{0}(\s.*)?\n$'.format(o), opt): conf[idx] = '#{0}'.format(opt) # Here we handle the "multiple-specifying" options- notably, HostKey. if isinstance(opts[o], list): for l in opts[o]: if l is not None: conf.append('{0} {1}\n'.format(o, l)) else: conf.append('{0}\n'.format(o)) else: # So it isn't something we explicitly save until the end (such as a Match or Host), # and it isn't something that's specified multiple times. if opts[o] is not None: conf.append('{0} {1}\n'.format(o, opts[o])) else: conf.append('{0}\n'.format(o)) # NOW we can add the Host/Match/etc. directives. for o in opts.keys(): if isinstance(opts[o], dict): for k in opts[o].keys(): conf.append('{0} {1}\n'.format(o, k)) for l in opts[o][k].keys(): if opts[o][k][l] is not None: conf.append('\t{0} {1}\n'.format(l, opts[o][k][l])) else: conf.append('\t{0}\n'.format(l)) with open(cf, 'w') as f: f.write(''.join(conf)) return() def clientKeys(user = 'root'): uid = pwd.getpwnam(user).pw_uid gid = pwd.getpwnam(user).pw_gid homedir = os.path.expanduser('~{0}'.format(user)) sshdir = '{0}/.ssh'.format(homedir) os.makedirs(sshdir, mode = 0o700, exist_ok = True) if has_ed25519: if not os.path.lexists('{0}/id_ed25519'.format(sshdir)) \ and not os.path.lexists('{0}/id_ed25519.pub'.format(sshdir)): subprocess.run(['ssh-keygen', '-t', 'ed25519', '-o', '-a', '100', '-f', '{0}/id_ed25519'.format(sshdir), '-q', '-N', '']) if not os.path.lexists('{0}/id_rsa'.format(sshdir)) and not os.path.lexists('{0}/id_rsa.pub'.format(sshdir)): if has_ed25519: subprocess.run(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-o', '-a', '100', '-f', '{0}/id_rsa'.format(sshdir), '-q', '-N', '']) else: subprocess.run(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-a', '100', '-f', '{0}/id_rsa'.format(sshdir), '-q', '-N', '']) for basedir, dirs, files in os.walk(sshdir): os.chown(basedir, uid, gid) os.chmod(basedir, 0o700) for f in files: os.chown(os.path.join(basedir, f), uid, gid) os.chmod(os.path.join(basedir, f), 0o600) if 'pubkeys' not in globals(): pubkeys = {} pubkeys[user] = {} for k in supported_keys: with open('{0}/id_{1}.pub'.format(sshdir, k), 'r') as f: pubkeys[user][k] = f.read() return(pubkeys) def daemonMgr(): # In case the script is running without sshd running. pidfile = '/var/run/sshd.pid' if not os.path.isfile(pidfile): return() # We're about to do somethin' stupid. Let's make it a teeny bit less stupid. with open(os.devnull, 'w') as devnull: confchk = subprocess.run(['sshd', '-T'], stdout = devnull) if confchk.returncode != 0: for suffix in ('', '.pub'): for k in glob.glob('/etc/ssh/ssh_host_*key{0}'.format(suffix)): os.rename('{0}.old.{1}'.format(k, tstamp), k) for conf in ('', 'd'): cf = '/etc/ssh/ssh{0}_config'.format(conf) os.rename('{0}.{1}'.format(cf, tstamp), cf) exit('OOPS. We goofed. Backup restored and bailing out.') # We need to restart sshd once we're done. I feel dirty doing this, but this is the most cross-platform way I can # do it. First, we need the path to the PID file. # TODO: do some kind of better way of doing this. with open('/etc/ssh/sshd_config', 'r') as f: for line in f.readlines(): if re.search('^\s*PidFile\s+.*', line): pidfile = re.sub('^\s*PidFile\s+(.*)(#.*)?$', '\g<1>', line) break with open(pidfile, 'r') as f: pid = int(f.read().strip()) os.kill(pid, signal.SIGHUP) return() def main(): self_pidfile = '/tmp/sshsecure.pid' is_running = False # First, check to see if we're already running. # This is where I'd put a psutil call... IF I HAD ONE. if os.path.isfile(self_pidfile): is_running = subprocess.run(['pgrep', '-F', self_pidfile], stdout = subprocess.PIPE) if is_running.stdout.decode('utf-8').strip() != '': # We're still running. Exit gracefully. print('We seem to still be running from a past execution; exiting') exit(0) else: # It's a stale PID file. os.remove(self_pidfile) with open(self_pidfile, 'w') as f: f.write(str(os.getpid()) + '\n') _chkfile = '/etc/ssh/.aif-generated' if not os.path.isfile(_chkfile): # Warning: The moduli stuff can take a LONG time to run. Hours. buildmoduli = True hostKeys(buildmoduli) for t in ('sshd', 'ssh'): config(conf_options[t], t) clientKeys() with open(_chkfile, 'w') as f: f.write(('ssh, sshd, and hostkey configurations/keys have been modified by sshsecure.py from OpTools.\n' 'https://git.square-r00t.net/OpTools/\n')) daemonMgr() os.remove(self_pidfile) return() if __name__ == '__main__': main()