optools/aif/scripts/post/sshsecure.py

286 lines
14 KiB
Python

#!/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.
# stdlib
import datetime
import glob
import os
import pwd
import re
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
#
conf_options = {}
conf_options['sshd'] = {'KexAlgorithms': 'curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256',
'Protocol': '2',
'HostKey': ['/etc/ssh/ssh_host_ed25519_key',
'/etc/ssh/ssh_host_rsa_key'],
'PermitRootLogin': 'prohibit-password',
'PasswordAuthentication': 'no',
'ChallengeResponseAuthentication': 'no',
'PubkeyAuthentication': 'yes',
'Ciphers': 'chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr',
'MACs': ('hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,' +
'hmac-sha2-512,hmac-sha2-256,umac-128@openssh.com')}
# Uncomment if this is further configured
#conf_options['sshd']['AllowGroups'] = 'ssh-user'
conf_options['ssh'] = {'Host': {'*': {'KexAlgorithms': 'curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256',
'PubkeyAuthentication': 'yes',
'HostKeyAlgorithms': 'ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ssh-ed25519,ssh-rsa'}}}
# Uncomment below if Github still needs diffie-hellman-group-exchange-sha1 sometimes.
# For what it's worth, it doesn't seem to.
#conf_options['ssh']['Host']['github.com'] = {'KexAlgorithms': 'curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256,' +
# 'diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1'}
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 Arch.
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:
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'])
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, int(datetime.datetime.utcnow().timestamp())))
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 ('ed25519', 'rsa'):
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, int(datetime.datetime.utcnow().timestamp())))
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])
#print(confopts)
# 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:
#print('commenting out old {0}'.format(o))
# 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):
#l = opt.split()
#conf[idx] = '#{0} {1}'.format(l[0].strip, l[1].strip)
#print('old {0}: {1}'.format(o, conf[idx]))
conf[idx] = '#{0}'.format(opt)
#print('new {0}: {1}'.format(o, conf[idx]))
# 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 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)):
subprocess.run(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-o', '-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 ('ed25519', 'rsa'):
with open('{0}/id_{1}.pub'.format(sshdir, k), 'r') as f:
pubkeys[user][k] = f.read()
return(pubkeys)
def main():
_chkfile = '/etc/ssh/.aif-generated'
if not os.path.isfile(_chkfile):
#Warning: The moduli stuff takes 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.\nhttps://git.square-r00t.net/OpTools/\n'))
return()
if __name__ == '__main__':
main()