diff --git a/.appveyor.yml b/.appveyor.yml
new file mode 100644
index 0000000..a367a30
--- /dev/null
+++ b/.appveyor.yml
@@ -0,0 +1,37 @@
+version: '1.7.1.dev.{build}'
+
+build: off
+branches:
+  only:
+    - master
+    - develop
+
+environment:
+  matrix:
+    - PYTHON: "C:\\Python26"
+    - PYTHON: "C:\\Python26-x64"
+    - PYTHON: "C:\\Python27"
+    - PYTHON: "C:\\Python27-x64"
+    - PYTHON: "C:\\Python33"
+    - PYTHON: "C:\\Python33-x64"
+    - PYTHON: "C:\\Python34"
+    - PYTHON: "C:\\Python34-x64"
+    - PYTHON: "C:\\Python35"
+    - PYTHON: "C:\\Python35-x64"
+    - PYTHON: "C:\\Python36"
+    - PYTHON: "C:\\Python36-x64"
+matrix:
+  fast_finish: true 
+
+cache:
+  - '%LOCALAPPDATA%\pip\Cache'
+  - .downloads -> .appveyor.yml
+
+install:
+  - "cmd /c .\\test\\tools\\ci-win.cmd install"
+
+test_script:
+  - "cmd /c .\\test\\tools\\ci-win.cmd test"
+
+on_failure:
+  - ps: get-content .tox\*\log\*
diff --git a/.gitignore b/.gitignore
index 481cc4a..9dc68e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
 *~
 *.pyc
-html/
-venv/
-.cache/
\ No newline at end of file
+venv*/
+.cache/
+.tox
+.coverage*
+reports/
+.scannerwork/
diff --git a/.travis.yml b/.travis.yml
index f1ee663..08daa94 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,18 +1,80 @@
 language: python
-python:
-  - 2.6
-  - 2.7
-  - 3.3
-  - 3.4
-  - 3.5
-  - pypy
-  - pypy3
-install:
-  - pip install --upgrade pytest
-  - pip install --upgrade pytest-cov
-  - pip install --upgrade coveralls
-script:
-  - py.test --cov-report= --cov=ssh-audit -v test
-after_success:
-  - coveralls
+sudo: false
+matrix:
+  include:
+    # (default)
+    - os: linux
+      python: 2.6
+    - os: linux
+      python: 2.7
+      env: SQ=1
+    - os: linux
+      python: 3.3
+    - os: linux
+      python: 3.4
+    - os: linux
+      python: 3.5
+    - os: linux
+      python: 3.6
+    - os: linux
+      python: pypy
+    - os: linux
+      python: pypy3
+    - os: linux
+      python: 3.7-dev
+    # Ubuntu 12.04
+    - os: linux
+      dist: precise
+      language: generic
+      env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 PY_ORIGIN=pyenv
+    # Ubuntu 14.04
+    - os: linux
+      dist: trusty
+      language: generic
+      env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3 PY_ORIGIN=pyenv
+    # macOS 10.12 Sierra
+    - os: osx
+      osx_image: xcode8.3
+      language: generic
+      env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3
+    # Mac OS X 10.11 El Capitan
+    - os: osx
+      osx_image: xcode7.3
+      language: generic
+      env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3
+    # Mac OS X 10.10 Yosemite
+    - os: osx
+      osx_image: xcode6.4
+      language: generic
+      env: PY_VER=py26,py27,py33,py34,py35,py36,pypy,pypy3
+  allow_failures:
+    # PyPy3 on Travis CI is out of date
+    - python: pypy3
+    # Python nightly could fail
+    - python: 3.7-dev
+    - env: PY_VER=py37
+    - env: PY_VER=py37/pyenv
+    - env: PY_VER=py37 PY_ORIGIN=pyenv
+  fast_finish: true
 
+cache:
+  - pip
+  - directories:
+    - $HOME/.pyenv.cache
+    - $HOME/.bin
+
+before_install:
+  - source test/tools/ci-linux.sh
+  - ci_step_before_install
+
+install:
+  - ci_step_install
+
+script:
+  - ci_step_script
+
+after_success:
+  - ci_step_success
+
+after_failure:
+  - ci_step_failure
diff --git a/LICENSE b/LICENSE
index 0eb1032..a778a9a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu)
+Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu)
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index e9f8f13..65281c2 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
 # ssh-audit
-[![build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg)](https://travis-ci.org/arthepsy/ssh-audit)
-[![coverage status](https://coveralls.io/repos/github/arthepsy/ssh-audit/badge.svg)](https://coveralls.io/github/arthepsy/ssh-audit)  
+[![travis build status](https://api.travis-ci.org/arthepsy/ssh-audit.svg?branch=develop)](https://travis-ci.org/arthepsy/ssh-audit)
+[![appveyor build status](https://ci.appveyor.com/api/projects/status/4m5r73m0r023edil/branch/develop?svg=true)](https://ci.appveyor.com/project/arthepsy/ssh-audit)
+[![codecov](https://codecov.io/gh/arthepsy/ssh-audit/branch/develop/graph/badge.svg)](https://codecov.io/gh/arthepsy/ssh-audit)
+[![Quality Gate](https://sonarqube.com/api/badges/gate?key=arthepsy-github%3Assh-audit%3Adevelop&template=ROUNDED)](https://sq.evolutiongaming.com/dashboard?id=arthepsy-github%3Assh-audit%3Adevelop)  
 **ssh-audit** is a tool for ssh server auditing.  
 
 ## Features
diff --git a/ssh-audit.py b/ssh-audit.py
index 8b67387..1f915f4 100755
--- a/ssh-audit.py
+++ b/ssh-audit.py
@@ -3,7 +3,7 @@
 """
    The MIT License (MIT)
    
-   Copyright (C) 2016 Andris Raugulis (moo@arthepsy.eu)
+   Copyright (C) 2017 Andris Raugulis (moo@arthepsy.eu)
    
    Permission is hereby granted, free of charge, to any person obtaining a copy
    of this software and associated documentation files (the "Software"), to deal
@@ -24,9 +24,9 @@
    THE SOFTWARE.
 """
 from __future__ import print_function
-import os, io, sys, socket, struct, random, errno, getopt, re, hashlib, base64
+import binascii, os, io, sys, socket, struct, random, errno, getopt, re, hashlib, base64
 
-VERSION = 'v1.7.0'
+VERSION = 'v1.7.1.dev'
 
 if sys.version_info >= (3,):  # pragma: nocover
 	StringIO, BytesIO = io.StringIO, io.BytesIO
@@ -39,7 +39,7 @@ else:  # pragma: nocover
 	binary_type = str
 try:  # pragma: nocover
 	# pylint: disable=unused-import
-	from typing import List, Set, Sequence, Tuple, Iterable
+	from typing import Dict, List, Set, Sequence, Tuple, Iterable
 	from typing import Callable, Optional, Union, Any
 except ImportError:  # pragma: nocover
 	pass
@@ -55,7 +55,7 @@ def usage(err=None):
 	uout = Output()
 	p = os.path.basename(sys.argv[0])
 	uout.head('# {0} {1}, moo@arthepsy.eu\n'.format(p, VERSION))
-	if err is not None:
+	if err is not None and len(err) > 0:
 		uout.fail('\n' + err)
 	uout.info('usage: {0} [-1246pbnvl] <host>\n'.format(p))
 	uout.info('   -h,  --help             print this help')
@@ -68,6 +68,7 @@ def usage(err=None):
 	uout.info('   -n,  --no-colors        disable colors')
 	uout.info('   -v,  --verbose          verbose output')
 	uout.info('   -l,  --level=<level>    minimum output level (info|warn|fail)')
+	uout.info('   -t,  --timeout=<secs>   timeout (in seconds) for connection and reading\n                           (default: 5)')
 	uout.sep()
 	sys.exit(1)
 
@@ -83,32 +84,33 @@ class AuditConf(object):
 		self.batch = False
 		self.colors = True
 		self.verbose = False
-		self.minlevel = 'info'
+		self.level = 'info'
 		self.ipvo = ()  # type: Sequence[int]
 		self.ipv4 = False
 		self.ipv6 = False
-	
+		self.timeout = 5.0
+
 	def __setattr__(self, name, value):
 		# type: (str, Union[str, int, bool, Sequence[int]]) -> None
 		valid = False
 		if name in ['ssh1', 'ssh2', 'batch', 'colors', 'verbose']:
-			valid, value = True, True if value else False
+			valid, value = True, True if bool(value) else False
 		elif name in ['ipv4', 'ipv6']:
 			valid = False
-			value = True if value else False
+			value = True if bool(value) else False
 			ipv = 4 if name == 'ipv4' else 6
 			if value:
 				value = tuple(list(self.ipvo) + [ipv])
-			else:
+			else:  # pylint: disable=else-if-used
 				if len(self.ipvo) == 0:
 					value = (6,) if ipv == 4 else (4,)
 				else:
-					value = tuple(filter(lambda x: x != ipv, self.ipvo))
+					value = tuple([x for x in self.ipvo if x != ipv])
 			self.__setattr__('ipvo', value)
 		elif name == 'ipvo':
 			if isinstance(value, (tuple, list)):
 				uniq_value = utils.unique_seq(value)
-				value = tuple(filter(lambda x: x in (4, 6), uniq_value))
+				value = tuple([x for x in uniq_value if x in (4, 6)])
 				valid = True
 				ipv_both = len(value) == 0
 				object.__setattr__(self, 'ipv4', ipv_both or 4 in value)
@@ -118,12 +120,17 @@ class AuditConf(object):
 			if port < 1 or port > 65535:
 				raise ValueError('invalid port: {0}'.format(value))
 			value = port
-		elif name in ['minlevel']:
+		elif name in ['level']:
 			if value not in ('info', 'warn', 'fail'):
 				raise ValueError('invalid level: {0}'.format(value))
 			valid = True
 		elif name == 'host':
 			valid = True
+		elif name == 'timeout':
+			value = utils.parse_float(value)
+			if value == -1.0:
+				raise ValueError('invalid timeout: {0}'.format(value))
+			valid = True
 		if valid:
 			object.__setattr__(self, name, value)
 	
@@ -133,9 +140,9 @@ class AuditConf(object):
 		# pylint: disable=too-many-branches
 		aconf = cls()
 		try:
-			sopts = 'h1246p:bnvl:'
+			sopts = 'h1246p:bnvl:t:'
 			lopts = ['help', 'ssh1', 'ssh2', 'ipv4', 'ipv6', 'port',
-			         'batch', 'no-colors', 'verbose', 'level=']
+			         'batch', 'no-colors', 'verbose', 'level=', 'timeout=']
 			opts, args = getopt.getopt(args, sopts, lopts)
 		except getopt.GetoptError as err:
 			usage_cb(str(err))
@@ -164,19 +171,24 @@ class AuditConf(object):
 			elif o in ('-l', '--level'):
 				if a not in ('info', 'warn', 'fail'):
 					usage_cb('level {0} is not valid'.format(a))
-				aconf.minlevel = a
+				aconf.level = a
+			elif o in ('-t', '--timeout'):
+				aconf.timeout = float(a)
 		if len(args) == 0:
 			usage_cb()
 		if oport is not None:
 			host = args[0]
-			port = utils.parse_int(oport)
 		else:
-			s = args[0].split(':')
-			host = s[0].strip()
-			if len(s) == 2:
-				oport, port = s[1], utils.parse_int(s[1])
+			mx = re.match(r'^\[([^\]]+)\](?::(.*))?$', args[0])
+			if bool(mx):
+				host, oport = mx.group(1), mx.group(2)
 			else:
-				oport, port = '22', 22
+				s = args[0].split(':')
+				if len(s) > 2:
+					host, oport = args[0], '22'
+				else:
+					host, oport = s[0], s[1] if len(s) > 1 else '22'
+		port = utils.parse_int(oport)
 		if not host:
 			usage_cb('host is empty')
 		if port <= 0 or port > 65535:
@@ -189,29 +201,30 @@ class AuditConf(object):
 
 
 class Output(object):
-	LEVELS = ['info', 'warn', 'fail']
+	LEVELS = ('info', 'warn', 'fail')  # type: Sequence[str]
 	COLORS = {'head': 36, 'good': 32, 'warn': 33, 'fail': 31}
 	
 	def __init__(self):
 		# type: () -> None
 		self.batch = False
-		self.colors = True
 		self.verbose = False
-		self.__minlevel = 0
+		self.use_colors = True
+		self.__level = 0
+		self.__colsupport = 'colorama' in sys.modules or os.name == 'posix'
 	
 	@property
-	def minlevel(self):
+	def level(self):
 		# type: () -> str
-		if self.__minlevel < len(self.LEVELS):
-			return self.LEVELS[self.__minlevel]
+		if self.__level < len(self.LEVELS):
+			return self.LEVELS[self.__level]
 		return 'unknown'
 	
-	@minlevel.setter
-	def minlevel(self, name):
+	@level.setter
+	def level(self, name):
 		# type: (str) -> None
-		self.__minlevel = self.getlevel(name)
+		self.__level = self.get_level(name)
 	
-	def getlevel(self, name):
+	def get_level(self, name):
 		# type: (str) -> int
 		cname = 'info' if name == 'good' else name
 		if cname not in self.LEVELS:
@@ -226,7 +239,7 @@ class Output(object):
 	@property
 	def colors_supported(self):
 		# type: () -> bool
-		return 'colorama' in sys.modules or os.name == 'posix'
+		return self.__colsupport
 	
 	@staticmethod
 	def _colorized(color):
@@ -237,9 +250,9 @@ class Output(object):
 		# type: (str) -> Callable[[text_type], None]
 		if name == 'head' and self.batch:
 			return lambda x: None
-		if not self.getlevel(name) >= self.__minlevel:
+		if not self.get_level(name) >= self.__level:
 			return lambda x: None
-		if self.colors and self.colors_supported and name in self.COLORS:
+		if self.use_colors and self.colors_supported and name in self.COLORS:
 			color = '\033[0;{0}m'.format(self.COLORS[name])
 			return self._colorized(color)
 		else:
@@ -267,6 +280,132 @@ class OutputBuffer(list):
 
 
 class SSH2(object):  # pylint: disable=too-few-public-methods
+	class KexDB(object):  # pylint: disable=too-few-public-methods
+		# pylint: disable=bad-whitespace
+		WARN_OPENSSH74_UNSAFE = 'disabled (in client) since OpenSSH 7.4, unsafe algorithm'
+		WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm'
+		FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm'
+		FAIL_OPENSSH70_WEAK   = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm'
+		FAIL_OPENSSH70_LOGJAM = 'disabled (in client) since OpenSSH 7.0, logjam attack'
+		INFO_OPENSSH69_CHACHA = 'default cipher since OpenSSH 6.9.'
+		FAIL_OPENSSH67_UNSAFE = 'removed (in server) since OpenSSH 6.7, unsafe algorithm'
+		FAIL_OPENSSH61_REMOVE = 'removed since OpenSSH 6.1, removed from specification'
+		FAIL_OPENSSH31_REMOVE = 'removed since OpenSSH 3.1'
+		FAIL_DBEAR67_DISABLED = 'disabled since Dropbear SSH 2015.67'
+		FAIL_DBEAR53_DISABLED = 'disabled since Dropbear SSH 0.53'
+		FAIL_DEPRECATED_CIPHER = 'deprecated cipher'
+		FAIL_WEAK_CIPHER      = 'using weak cipher'
+		FAIL_PLAINTEXT        = 'no encryption/integrity'
+		WARN_CURVES_WEAK      = 'using weak elliptic curves'
+		WARN_RNDSIG_KEY       = 'using weak random number generator could reveal the key'
+		WARN_MODULUS_SIZE     = 'using small 1024-bit modulus'
+		WARN_HASH_WEAK        = 'using weak hashing algorithm'
+		WARN_CIPHER_MODE      = 'using weak cipher mode'
+		WARN_BLOCK_SIZE       = 'using small 64-bit block size'
+		WARN_CIPHER_WEAK      = 'using weak cipher'
+		WARN_ENCRYPT_AND_MAC  = 'using encrypt-and-MAC mode'
+		WARN_TAG_SIZE         = 'using small 64-bit tag size'
+		
+		ALGORITHMS = {
+			'kex': {
+				'diffie-hellman-group1-sha1': [['2.3.0,d0.28,l10.2', '6.6', '6.9'], [FAIL_OPENSSH67_UNSAFE, FAIL_OPENSSH70_LOGJAM], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]],
+				'diffie-hellman-group14-sha1': [['3.9,d0.53,l10.6.0'], [], [WARN_HASH_WEAK]],
+				'diffie-hellman-group14-sha256': [['7.3,d2016.73']],
+				'diffie-hellman-group15-sha512': [[]],
+				'diffie-hellman-group16-sha512': [['7.3,d2016.73']],
+				'diffie-hellman-group18-sha512': [['7.3']],
+				'diffie-hellman-group-exchange-sha1': [['2.3.0', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]],
+				'diffie-hellman-group-exchange-sha256': [['4.4']],
+				'ecdh-sha2-nistp256': [['5.7,d2013.62,l10.6.0'], [WARN_CURVES_WEAK]],
+				'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]],
+				'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]],
+				'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']],
+				'curve25519-sha256': [['7.4']],
+				'kexguess2@matt.ucc.asn.au': [['d2013.57']],
+				'rsa1024-sha1': [[], [], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]],
+				'rsa2048-sha256': [[]],
+			},
+			'key': {
+				'rsa-sha2-256': [['7.2']],
+				'rsa-sha2-512': [['7.2']],
+				'ssh-ed25519': [['6.5,l10.7.0']],
+				'ssh-ed25519-cert-v01@openssh.com': [['6.5']],
+				'ssh-rsa': [['2.5.0,d0.28,l10.2']],
+				'ssh-dss': [['2.1.0,d0.28,l10.2', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp256': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp384': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp521': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ssh-rsa-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], []],
+				'ssh-dss-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
+				'ssh-rsa-cert-v01@openssh.com': [['5.6']],
+				'ssh-dss-cert-v01@openssh.com': [['5.6', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp256-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp384-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ecdsa-sha2-nistp521-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
+				'ssh-rsa-sha256@ssh.com': [[]],
+			},
+			'enc': {
+				'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]],
+				'des-cbc': [[], [FAIL_WEAK_CIPHER], [WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
+				'3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH74_UNSAFE, WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
+				'3des-ctr': [['d0.52']],
+				'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
+				'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
+				'twofish128-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
+				'twofish192-cbc': [[], [], [WARN_CIPHER_MODE]],
+				'twofish256-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
+				'twofish128-ctr': [['d2015.68']],
+				'twofish192-ctr': [[]],
+				'twofish256-ctr': [['d2015.68']],
+				'serpent128-ctr': [[], [FAIL_DEPRECATED_CIPHER]],
+				'serpent192-ctr': [[], [FAIL_DEPRECATED_CIPHER]],
+				'serpent256-ctr': [[], [FAIL_DEPRECATED_CIPHER]],
+				'idea-ctr': [[], [FAIL_DEPRECATED_CIPHER]],
+				'cast128-ctr': [[], [FAIL_DEPRECATED_CIPHER]],
+				'cast128-cbc': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
+				'arcfour': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
+				'arcfour128': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
+				'arcfour256': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
+				'aes128-cbc': [['2.3.0,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
+				'aes192-cbc': [['2.3.0,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
+				'aes256-cbc': [['2.3.0,d0.47,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
+				'rijndael128-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
+				'rijndael192-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
+				'rijndael256-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
+				'rijndael-cbc@lysator.liu.se': [['2.3.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE]],
+				'aes128-ctr': [['3.7,d0.52,l10.4.1']],
+				'aes192-ctr': [['3.7,l10.4.1']],
+				'aes256-ctr': [['3.7,d0.52,l10.4.1']],
+				'aes128-gcm@openssh.com': [['6.2']],
+				'aes256-gcm@openssh.com': [['6.2']],
+				'chacha20-poly1305@openssh.com': [['6.5'], [], [], [INFO_OPENSSH69_CHACHA]],
+			},
+			'mac': {
+				'none': [['d2013.56'], [FAIL_PLAINTEXT]],
+				'hmac-sha1': [['2.1.0,d0.28,l10.2'], [], [WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
+				'hmac-sha1-96': [['2.5.0,d0.47', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
+				'hmac-sha2-256': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]],
+				'hmac-sha2-256-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]],
+				'hmac-sha2-512': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]],
+				'hmac-sha2-512-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]],
+				'hmac-md5': [['2.1.0,d0.28', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
+				'hmac-md5-96': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
+				'hmac-ripemd160': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]],
+				'hmac-ripemd160@openssh.com': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]],
+				'umac-64@openssh.com': [['4.7'], [], [WARN_ENCRYPT_AND_MAC, WARN_TAG_SIZE]],
+				'umac-128@openssh.com': [['6.2'], [], [WARN_ENCRYPT_AND_MAC]],
+				'hmac-sha1-etm@openssh.com': [['6.2'], [], [WARN_HASH_WEAK]],
+				'hmac-sha1-96-etm@openssh.com': [['6.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]],
+				'hmac-sha2-256-etm@openssh.com': [['6.2']],
+				'hmac-sha2-512-etm@openssh.com': [['6.2']],
+				'hmac-md5-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]],
+				'hmac-md5-96-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]],
+				'hmac-ripemd160-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY]],
+				'umac-64-etm@openssh.com': [['6.2'], [], [WARN_TAG_SIZE]],
+				'umac-128-etm@openssh.com': [['6.2']],
+			}
+		}  # type: Dict[str, Dict[str, List[List[Optional[str]]]]]
+	
 	class KexParty(object):
 		def __init__(self, enc, mac, compression, languages):
 			# type: (List[text_type], List[text_type], List[text_type], List[text_type]) -> None
@@ -305,7 +444,10 @@ class SSH2(object):  # pylint: disable=too-few-public-methods
 			self.__server = srv
 			self.__follows = follows
 			self.__unused = unused
-		
+
+			self.__rsa_key_sizes = {}
+			self.__dh_modulus_sizes = {}
+
 		@property
 		def cookie(self):
 			# type: () -> binary_type
@@ -342,7 +484,19 @@ class SSH2(object):  # pylint: disable=too-few-public-methods
 		def unused(self):
 			# type: () -> int
 			return self.__unused
-		
+
+		def set_rsa_key_size(self, rsa_type, hostkey_size, ca_size=-1):
+			self.__rsa_key_sizes[rsa_type] = (hostkey_size, ca_size)
+
+		def rsa_key_sizes(self):
+			return self.__rsa_key_sizes
+
+		def set_dh_modulus_size(self, gex_alg, modulus_size):
+			self.__dh_modulus_sizes[gex_alg] = (modulus_size, -1)
+
+		def dh_modulus_sizes(self):
+			return self.__dh_modulus_sizes
+
 		def write(self, wbuf):
 			# type: (WriteBuf) -> None
 			wbuf.write(self.cookie)
@@ -388,6 +542,242 @@ class SSH2(object):  # pylint: disable=too-few-public-methods
 			kex = cls(cookie, kex_algs, key_algs, cli, srv, follows, unused)
 			return kex
 
+	# Obtains RSA host keys and checks their size.
+	class RSAKeyTest(object):
+		RSA_TYPES = ['ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512']
+		RSA_CA_TYPES = ['ssh-rsa-cert-v01@openssh.com']
+
+		@staticmethod
+		def run(s, server_kex):
+			KEX_TO_DHGROUP = {
+				'diffie-hellman-group1-sha1': KexGroup1,
+				'diffie-hellman-group14-sha1': KexGroup14_SHA1,
+				'diffie-hellman-group14-sha256': KexGroup14_SHA256,
+				'curve25519-sha256': KexCurve25519_SHA256,
+				'curve25519-sha256@libssh.org': KexCurve25519_SHA256,
+				'diffie-hellman-group16-sha512': KexGroup16_SHA512,
+				'diffie-hellman-group18-sha512': KexGroup18_SHA512,
+				'diffie-hellman-group-exchange-sha1': KexGroupExchange_SHA1,
+				'diffie-hellman-group-exchange-sha256': KexGroupExchange_SHA256,
+				'ecdh-sha2-nistp256': KexNISTP256,
+				'ecdh-sha2-nistp384': KexNISTP384,
+				'ecdh-sha2-nistp521': KexNISTP521,
+				#'kexguess2@matt.ucc.asn.au': ???
+			}
+
+			# Pick the first kex algorithm that the server supports, which we
+			# happen to support as well.
+			kex_str = None
+			kex_group = None
+			for server_kex_alg in server_kex.kex_algorithms:
+				if server_kex_alg in KEX_TO_DHGROUP:
+					kex_str = server_kex_alg
+					kex_group = KEX_TO_DHGROUP[kex_str]()
+					break
+
+			if kex_str is not None:
+				SSH2.RSAKeyTest.__test(s, server_kex, kex_str, kex_group, SSH2.RSAKeyTest.RSA_TYPES)
+				SSH2.RSAKeyTest.__test(s, server_kex, kex_str, kex_group, SSH2.RSAKeyTest.RSA_CA_TYPES, ca=True)
+
+		@staticmethod
+		def __test(s, server_kex, kex_str, kex_group, rsa_types, ca=False):
+			# If the server supports one of the RSA types, extract its key size.
+			hostkey_modulus_size = 0
+			ca_modulus_size = 0
+			ran_test = False
+
+			# If the connection is closed, re-open it and get the kex again.
+			if not s.is_connected():
+				s.connect()
+				unused = None # pylint: disable=unused-variable
+				unused, unused, err = s.get_banner()
+				if err is not None:
+					s.close()
+					return
+
+				# Parse the server's initial KEX.
+				packet_type = 0 # pylint: disable=unused-variable
+				packet_type, payload = s.read_packet()
+				SSH2.Kex.parse(payload)
+
+
+			for rsa_type in rsa_types:
+				if rsa_type in server_kex.key_algorithms:
+					ran_test = True
+
+					# Send the server our KEXINIT message, using only our
+					# selected kex and RSA type.  Send the server's own
+					# list of ciphers and MACs back to it (this doesn't
+					# matter, really).
+					client_kex = SSH2.Kex(os.urandom(16), [kex_str], [rsa_type], server_kex.client, server_kex.server, 0, 0)
+
+					s.write_byte(SSH.Protocol.MSG_KEXINIT)
+					client_kex.write(s)
+					s.send_packet()
+
+					# Do the initial DH exchange.  The server responds back
+					# with the host key and its length.  Bingo.
+					kex_group.send_init(s)
+					kex_group.recv_reply(s)
+
+					hostkey_modulus_size = kex_group.get_hostkey_size()
+					ca_modulus_size = kex_group.get_ca_size()
+
+					# If we're not working with the CA types, we only need to
+					# test one RSA key, since the others will all be the same.
+					if ca is False:
+						break
+
+			if hostkey_modulus_size > 0 or ca_modulus_size > 0:
+				# Set the hostkey size for all RSA key types since 'ssh-rsa',
+				# 'rsa-sha2-256', etc. are all using the same host key.
+				# Note, however, that this may change in the future.
+				if ca is False:
+					for rsa_type in rsa_types:
+						server_kex.set_rsa_key_size(rsa_type, hostkey_modulus_size)
+				else:
+					server_kex.set_rsa_key_size(rsa_type, hostkey_modulus_size, ca_modulus_size)
+
+				# Keys smaller than 2048 result in a failure.
+				fail = False
+				if hostkey_modulus_size < 2048 or (ca_modulus_size < 2048 and ca_modulus_size > 0):
+					fail = True
+
+				# If this is a bad key size, update the database accordingly.
+				if fail:
+					if ca is False:
+						for rsa_type in SSH2.RSAKeyTest.RSA_TYPES:
+							alg_list = SSH2.KexDB.ALGORITHMS['key'][rsa_type]
+							alg_list.append(['using small %d-bit modulus' % hostkey_modulus_size])
+					else:
+						alg_list = SSH2.KexDB.ALGORITHMS['key'][rsa_type]
+
+						min_modulus = min(hostkey_modulus_size, ca_modulus_size)
+						min_modulus = min_modulus if min_modulus > 0 else max(hostkey_modulus_size, ca_modulus_size)
+						alg_list.append(['using small %d-bit modulus' % min_modulus])
+
+			# If we ran any tests, close the socket, as the connection has
+			# been put in a state that later tests can't use.
+			if ran_test:
+				s.close()
+
+	# Performs DH group exchanges to find what moduli are supported, and checks
+	# their size.
+	class GEXTest(object):
+
+		# Creates a new connection to the server.  Returns an SSH.Socket, or
+		# None on failure.
+		@staticmethod
+		def reconnect(s, gex_alg):
+			if s.is_connected():
+				return
+
+			s.connect()
+			unused = None # pylint: disable=unused-variable
+			unused, unused, err = s.get_banner()
+			if err is not None:
+				s.close()
+				return False
+
+			# Parse the server's initial KEX.
+			packet_type = 0 # pylint: disable=unused-variable
+			packet_type, payload = s.read_packet(2)
+			kex = SSH2.Kex.parse(payload)
+
+			# Send our KEX using the specified group-exchange and most of the
+			# server's own values.
+			client_kex = SSH2.Kex(os.urandom(16), [gex_alg], kex.key_algorithms, kex.client, kex.server, 0, 0)
+			s.write_byte(SSH.Protocol.MSG_KEXINIT)
+			client_kex.write(s)
+			s.send_packet()
+			return True
+
+		# Runs the DH moduli test against the specified target.
+		@staticmethod
+		def run(s, kex):
+			GEX_ALGS = {
+				'diffie-hellman-group-exchange-sha1': KexGroupExchange_SHA1,
+				'diffie-hellman-group-exchange-sha256': KexGroupExchange_SHA256,
+			}
+
+			# The previous RSA tests put the server in a state we can't
+			# test.  So we need a new connection to start with a clean
+			# slate.
+			if s.is_connected():
+				s.close()
+
+			# Check if the server supports any of the group-exchange
+			# algorithms.  If so, test each one.
+			for gex_alg in GEX_ALGS:
+				if gex_alg in kex.kex_algorithms:
+
+					if SSH2.GEXTest.reconnect(s, gex_alg) is False:
+						break
+
+
+					kex_group = GEX_ALGS[gex_alg]()
+					smallest_modulus = -1
+
+					# First try a range of weak sizes.
+					try:
+						kex_group.send_init_gex(s, 512, 1024, 1536)
+						kex_group.recv_reply(s)
+
+						# Its been observed that servers will return a group
+						# larger than the requested max.  So just because we
+						# got here, doesn't mean the server is vulnerable...
+						smallest_modulus = kex_group.get_dh_modulus_size()
+					except Exception: # pylint: disable=bare-except
+						x = 1 # pylint: disable=unused-variable
+					finally:
+						s.close()
+
+					# Try an array of specific modulus sizes... one at a time.
+					reconnect_failed = False
+					for bits in [512, 768, 1024, 1536, 2048, 3072, 4096]:
+
+						# If we found one modulus size already, but we're about
+						# to test a larger one, don't bother.
+						if smallest_modulus > 0 and bits >= smallest_modulus:
+							break
+
+						if SSH2.GEXTest.reconnect(s, gex_alg) is False:
+							reconnect_failed = True
+							break
+
+						try:
+							kex_group.send_init_gex(s, bits, bits, bits)
+							kex_group.recv_reply(s)
+							smallest_modulus = kex_group.get_dh_modulus_size()
+						except Exception: # pylint: disable=bare-except
+							x = 1 # pylint: disable=unused-variable
+						finally:
+							# The server is in a state that is not re-testable,
+							# so there's nothing else to do with this open
+							# connection.
+							s.close()
+
+
+					if smallest_modulus > 0:
+						kex.set_dh_modulus_size(gex_alg, smallest_modulus)
+
+						# We flag moduli smaller than 2048 as a failure.
+						if smallest_modulus < 2048:
+							text = 'using small %d-bit modulus' % smallest_modulus
+							lst = SSH2.KexDB.ALGORITHMS['kex'][gex_alg]
+							# For 'diffie-hellman-group-exchange-sha256', add
+							# a failure reason.
+							if len(lst) == 1:
+								lst.append([text])
+							# For 'diffie-hellman-group-exchange-sha1', delete
+							# the existing failure reason (which is vague), and
+							# insert our own.
+							else:
+								del lst[1]
+								lst.insert(1, [text])
+
+					if reconnect_failed:
+						break
 
 class SSH1(object):
 	class CRC32(object):
@@ -414,7 +804,7 @@ class SSH1(object):
 	
 	_crc32 = None  # type: Optional[SSH1.CRC32]
 	CIPHERS = ['none', 'idea', 'des', '3des', 'tss', 'rc4', 'blowfish']
-	AUTHS = [None, 'rhosts', 'rsa', 'password', 'rhosts_rsa', 'tis', 'kerberos']
+	AUTHS = ['none', 'rhosts', 'rsa', 'password', 'rhosts_rsa', 'tis', 'kerberos']
 	
 	@classmethod
 	def crc32(cls, v):
@@ -452,13 +842,15 @@ class SSH1(object):
 				'tis': [['1.2.2']],
 				'kerberos': [['1.2.2', '3.6'], [FAIL_OPENSSH37_REMOVE]],
 			}
-		}  # type: Dict[str, Dict[str, List[List[str]]]]
+		}  # type: Dict[str, Dict[str, List[List[Optional[str]]]]]
 	
 	class PublicKeyMessage(object):
 		def __init__(self, cookie, skey, hkey, pflags, cmask, amask):
 			# type: (binary_type, Tuple[int, int, int], Tuple[int, int, int], int, int, int) -> None
-			assert len(skey) == 3
-			assert len(hkey) == 3
+			if len(skey) != 3:
+				raise ValueError('invalid server key pair: {0}'.format(skey))
+			if len(hkey) != 3:
+				raise ValueError('invalid host key pair: {0}'.format(hkey))
 			self.__cookie = cookie
 			self.__server_key = skey
 			self.__host_key = hkey
@@ -586,8 +978,8 @@ class ReadBuf(object):
 	def __init__(self, data=None):
 		# type: (Optional[binary_type]) -> None
 		super(ReadBuf, self).__init__()
-		self._buf = BytesIO(data) if data else BytesIO()
-		self._len = len(data) if data else 0
+		self._buf = BytesIO(data) if data is not None else BytesIO()
+		self._len = len(data) if data is not None else 0
 	
 	@property
 	def unread_len(self):
@@ -600,7 +992,8 @@ class ReadBuf(object):
 	
 	def read_byte(self):
 		# type: () -> int
-		return struct.unpack('B', self.read(1))[0]
+		v = struct.unpack('B', self.read(1))[0]  # type: int
+		return v
 	
 	def read_bool(self):
 		# type: () -> bool
@@ -608,7 +1001,8 @@ class ReadBuf(object):
 	
 	def read_int(self):
 		# type: () -> int
-		return struct.unpack('>I', self.read(4))[0]
+		v = struct.unpack('>I', self.read(4))[0]  # type: int
+		return v
 	
 	def read_list(self):
 		# type: () -> List[text_type]
@@ -621,13 +1015,13 @@ class ReadBuf(object):
 		return self.read(n)
 	
 	@classmethod
-	def _parse_mpint(cls, v, pad, sf):
+	def _parse_mpint(cls, v, pad, f):
 		# type: (binary_type, binary_type, str) -> int
 		r = 0
-		if len(v) % 4:
+		if len(v) % 4 != 0:
 			v = pad * (4 - (len(v) % 4)) + v
 		for i in range(0, len(v), 4):
-			r = (r << 32) | struct.unpack(sf, v[i:i + 4])[0]
+			r = (r << 32) | struct.unpack(f, v[i:i + 4])[0]
 		return r
 		
 	def read_mpint1(self):
@@ -643,19 +1037,23 @@ class ReadBuf(object):
 		v = self.read_string()
 		if len(v) == 0:
 			return 0
-		pad, sf = (b'\xff', '>i') if ord(v[0:1]) & 0x80 else (b'\x00', '>I')
-		return self._parse_mpint(v, pad, sf)
+		pad, f = (b'\xff', '>i') if ord(v[0:1]) & 0x80 != 0 else (b'\x00', '>I')
+		return self._parse_mpint(v, pad, f)
 	
 	def read_line(self):
 		# type: () -> text_type
 		return self._buf.readline().rstrip().decode('utf-8', 'replace')
 
+	def reset(self):
+		self._buf = BytesIO()
+		self._len = 0
+		super(ReadBuf, self).reset()
 
 class WriteBuf(object):
 	def __init__(self, data=None):
 		# type: (Optional[binary_type]) -> None
 		super(WriteBuf, self).__init__()
-		self._wbuf = BytesIO(data) if data else BytesIO()
+		self._wbuf = BytesIO(data) if data is not None else BytesIO()
 	
 	def write(self, data):
 		# type: (binary_type) -> WriteBuf
@@ -702,7 +1100,7 @@ class WriteBuf(object):
 		ql = (length + 7) // 8
 		fmt, v2 = '>{0}Q'.format(ql), [0] * ql
 		for i in range(ql):
-			v2[ql - i - 1] = (n & 0xffffffffffffffff)
+			v2[ql - i - 1] = n & 0xffffffffffffffff
 			n >>= 64
 		data = bytes(struct.pack(fmt, *v2)[-length:])
 		if not signed:
@@ -739,6 +1137,9 @@ class WriteBuf(object):
 		self._wbuf.seek(0)
 		return payload
 
+	def reset(self):
+		self._wbuf = BytesIO()
+
 
 class SSH(object):  # pylint: disable=too-few-public-methods
 	class Protocol(object):  # pylint: disable=too-few-public-methods
@@ -747,7 +1148,11 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		MSG_KEXINIT     = 20
 		MSG_NEWKEYS     = 21
 		MSG_KEXDH_INIT  = 30
-		MSG_KEXDH_REPLY = 32
+		MSG_KEXDH_REPLY = 31
+		MSG_KEXDH_GEX_REQUEST = 34
+		MSG_KEXDH_GEX_GROUP   = 31
+		MSG_KEXDH_GEX_INIT    = 32
+		MSG_KEXDH_GEX_REPLY   = 33
 	
 	class Product(object):  # pylint: disable=too-few-public-methods
 		OpenSSH = 'OpenSSH'
@@ -798,7 +1203,7 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			else:
 				other = str(other)
 			mx = re.match(r'^([\d\.]+\d+)(.*)$', other)
-			if mx:
+			if bool(mx):
 				oversion, opatch = mx.group(1), mx.group(2).strip()
 			else:
 				oversion, opatch = other, ''
@@ -815,10 +1220,10 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			elif self.product == SSH.Product.OpenSSH:
 				mx1 = re.match(r'^p\d(.*)', opatch)
 				mx2 = re.match(r'^p\d(.*)', spatch)
-				if not (mx1 and mx2):
-					if mx1:
+				if not (bool(mx1) and bool(mx2)):
+					if bool(mx1):
 						opatch = mx1.group(1)
-					if mx2:
+					if bool(mx2):
 						spatch = mx2.group(1)
 			if spatch < opatch:
 				return -1
@@ -828,28 +1233,28 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		def between_versions(self, vfrom, vtill):
 			# type: (str, str) -> bool
-			if vfrom and self.compare_version(vfrom) < 0:
+			if bool(vfrom) and self.compare_version(vfrom) < 0:
 				return False
-			if vtill and self.compare_version(vtill) > 0:
+			if bool(vtill) and self.compare_version(vtill) > 0:
 				return False
 			return True
 		
 		def display(self, full=True):
 			# type: (bool) -> str
-			r = '{0} '.format(self.vendor) if self.vendor else ''
+			r = '{0} '.format(self.vendor) if bool(self.vendor) else ''
 			r += self.product
-			if self.version:
+			if bool(self.version):
 				r += ' {0}'.format(self.version)
 			if full:
 				patch = self.patch or ''
 				if self.product == SSH.Product.OpenSSH:
 					mx = re.match(r'^(p\d)(.*)$', patch)
-					if mx is not None:
+					if bool(mx):
 						r += mx.group(1)
 						patch = mx.group(2).strip()
-				if patch:
+				if bool(patch):
 					r += ' ({0})'.format(patch)
-				if self.os:
+				if bool(self.os):
 					r += ' running on {0}'.format(self.os)
 			return r
 		
@@ -859,16 +1264,13 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		def __repr__(self):
 			# type: () -> str
-			r = 'vendor={0}'.format(self.vendor) if self.vendor else ''
-			if self.product:
-				if self.vendor:
-					r += ', '
-				r += 'product={0}'.format(self.product)
-			if self.version:
+			r = 'vendor={0}, '.format(self.vendor) if bool(self.vendor) else ''
+			r += 'product={0}'.format(self.product)
+			if bool(self.version):
 				r += ', version={0}'.format(self.version)
-			if self.patch:
+			if bool(self.patch):
 				r += ', patch={0}'.format(self.patch)
-			if self.os:
+			if bool(self.os):
 				r += ', os={0}'.format(self.os)
 			return '<{0}({1})>'.format(self.__class__.__name__, r)
 		
@@ -887,23 +1289,23 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		@classmethod
 		def _extract_os_version(cls, c):
-			# type: (Optional[str]) -> str
+			# type: (Optional[str]) -> Optional[str]
 			if c is None:
 				return None
 			mx = re.match(r'^NetBSD(?:_Secure_Shell)?(?:[\s-]+(\d{8})(.*))?$', c)
-			if mx:
+			if bool(mx):
 				d = cls._fix_date(mx.group(1))
 				return 'NetBSD' if d is None else 'NetBSD ({0})'.format(d)
 			mx = re.match(r'^FreeBSD(?:\slocalisations)?[\s-]+(\d{8})(.*)$', c)
-			if not mx:
+			if not bool(mx):
 				mx = re.match(r'^[^@]+@FreeBSD\.org[\s-]+(\d{8})(.*)$', c)
-			if mx:
+			if bool(mx):
 				d = cls._fix_date(mx.group(1))
 				return 'FreeBSD' if d is None else 'FreeBSD ({0})'.format(d)
 			w = ['RemotelyAnywhere', 'DesktopAuthority', 'RemoteSupportManager']
 			for win_soft in w:
 				mx = re.match(r'^in ' + win_soft + r' ([\d\.]+\d)$', c)
-				if mx:
+				if bool(mx):
 					ver = mx.group(1)
 					return 'Microsoft Windows ({0} {1})'.format(win_soft, ver)
 			generic = ['NetBSD', 'FreeBSD']
@@ -914,39 +1316,40 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		@classmethod
 		def parse(cls, banner):
-			# type: (SSH.Banner) -> SSH.Software
+			# type: (SSH.Banner) -> Optional[SSH.Software]
 			# pylint: disable=too-many-return-statements
 			software = str(banner.software)
 			mx = re.match(r'^dropbear_([\d\.]+\d+)(.*)', software)
-			if mx:
+			v = None  # type: Optional[str]
+			if bool(mx):
 				patch = cls._fix_patch(mx.group(2))
 				v, p = 'Matt Johnston', SSH.Product.DropbearSSH
 				v = None
 				return cls(v, p, mx.group(1), patch, None)
 			mx = re.match(r'^OpenSSH[_\.-]+([\d\.]+\d+)(.*)', software)
-			if mx:
+			if bool(mx):
 				patch = cls._fix_patch(mx.group(2))
 				v, p = 'OpenBSD', SSH.Product.OpenSSH
 				v = None
 				os_version = cls._extract_os_version(banner.comments)
 				return cls(v, p, mx.group(1), patch, os_version)
 			mx = re.match(r'^libssh-([\d\.]+\d+)(.*)', software)
-			if mx:
+			if bool(mx):
 				patch = cls._fix_patch(mx.group(2))
 				v, p = None, SSH.Product.LibSSH
 				os_version = cls._extract_os_version(banner.comments)
 				return cls(v, p, mx.group(1), patch, os_version)
 			mx = re.match(r'^RomSShell_([\d\.]+\d+)(.*)', software)
-			if mx:
+			if bool(mx):
 				patch = cls._fix_patch(mx.group(2))
 				v, p = 'Allegro Software', 'RomSShell'
 				return cls(v, p, mx.group(1), patch, None)
 			mx = re.match(r'^mpSSH_([\d\.]+\d+)', software)
-			if mx:
+			if bool(mx):
 				v, p = 'HP', 'iLO (Integrated Lights-Out) sshd'
 				return cls(v, p, mx.group(1), None, None)
 			mx = re.match(r'^Cisco-([\d\.]+\d+)', software)
-			if mx:
+			if bool(mx):
 				v, p = 'Cisco', 'IOS/PIX sshd'
 				return cls(v, p, mx.group(1), None, None)
 			return None
@@ -957,7 +1360,7 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		RX_BANNER = re.compile(r'^({0}(?:(?:-{0})*)){1}$'.format(_RXP, _RXR))
 		
 		def __init__(self, protocol, software, comments, valid_ascii):
-			# type: (Tuple[int, int], str, str, bool) -> None
+			# type: (Tuple[int, int], Optional[str], Optional[str], bool) -> None
 			self.__protocol = protocol
 			self.__software = software
 			self.__comments = comments
@@ -970,12 +1373,12 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		@property
 		def software(self):
-			# type: () -> str
+			# type: () -> Optional[str]
 			return self.__software
 		
 		@property
 		def comments(self):
-			# type: () -> str
+			# type: () -> Optional[str]
 			return self.__comments
 		
 		@property
@@ -988,7 +1391,7 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			r = 'SSH-{0}.{1}'.format(self.protocol[0], self.protocol[1])
 			if self.software is not None:
 				r += '-{0}'.format(self.software)
-			if self.comments:
+			if bool(self.comments):
 				r += ' {0}'.format(self.comments)
 			return r
 		
@@ -996,19 +1399,19 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			# type: () -> str
 			p = '{0}.{1}'.format(self.protocol[0], self.protocol[1])
 			r = 'protocol={0}'.format(p)
-			if self.software:
+			if self.software is not None:
 				r += ', software={0}'.format(self.software)
-			if self.comments:
+			if bool(self.comments):
 				r += ', comments={0}'.format(self.comments)
 			return '<{0}({1})>'.format(self.__class__.__name__, r)
 		
 		@classmethod
 		def parse(cls, banner):
-			# type: (text_type) -> SSH.Banner
-			valid_ascii = utils.is_ascii(banner)
-			ascii_banner = utils.to_ascii(banner)
+			# type: (text_type) -> Optional[SSH.Banner]
+			valid_ascii = utils.is_print_ascii(banner)
+			ascii_banner = utils.to_print_ascii(banner)
 			mx = cls.RX_BANNER.match(ascii_banner)
-			if mx is None:
+			if not bool(mx):
 				return None
 			protocol = min(re.findall(cls.RX_PROTOCOL, mx.group(1)))
 			protocol = (int(protocol[0]), int(protocol[1]))
@@ -1039,16 +1442,308 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			r = h.decode('ascii').rstrip('=')
 			return u'SHA256:{0}'.format(r)
 	
+	class Algorithm(object):
+		class Timeframe(object):
+			def __init__(self):
+				# type: () -> None
+				self.__storage = {}  # type: Dict[str, List[Optional[str]]]
+			
+			def __contains__(self, product):
+				# type: (str) -> bool
+				return product in self.__storage
+			
+			def __getitem__(self, product):
+				# type: (str) -> Sequence[Optional[str]]
+				return tuple(self.__storage.get(product, [None]*4))
+			
+			def __str__(self):
+				# type: () -> str
+				return self.__storage.__str__()
+			
+			def __repr__(self):
+				# type: () -> str
+				return self.__str__()
+			
+			def get_from(self, product, for_server=True):
+				# type: (str, bool) -> Optional[str]
+				return self[product][0 if bool(for_server) else 2]
+			
+			def get_till(self, product, for_server=True):
+				# type: (str, bool) -> Optional[str]
+				return self[product][1 if bool(for_server) else 3]
+			
+			def _update(self, versions, pos):
+				# type: (Optional[str], int) -> None
+				ssh_versions = {}  # type: Dict[str, str]
+				for_srv, for_cli = pos < 2, pos > 1
+				for v in (versions or '').split(','):
+					ssh_prod, ssh_ver, is_cli = SSH.Algorithm.get_ssh_version(v)
+					if (not ssh_ver or
+					   (is_cli and for_srv) or
+					   (not is_cli and for_cli and ssh_prod in ssh_versions)):
+						continue
+					ssh_versions[ssh_prod] = ssh_ver
+				for ssh_product, ssh_version in ssh_versions.items():
+					if ssh_product not in self.__storage:
+						self.__storage[ssh_product] = [None]*4
+					prev = self[ssh_product][pos]
+					if (prev is None or
+					   (prev < ssh_version and pos % 2 == 0) or
+					   (prev > ssh_version and pos % 2 == 1)):
+						self.__storage[ssh_product][pos] = ssh_version
+			
+			def update(self, versions, for_server=None):
+				# type: (List[Optional[str]], Optional[bool]) -> SSH.Algorithm.Timeframe
+				for_cli = for_server is None or for_server is False
+				for_srv = for_server is None or for_server is True
+				vlen = len(versions)
+				for i in range(min(3, vlen)):
+					if for_srv and i < 2:
+						self._update(versions[i], i)
+					if for_cli and (i % 2 == 0 or vlen == 2):
+						self._update(versions[i], 3 - 0**i)
+				return self
+		
+		@staticmethod
+		def get_ssh_version(version_desc):
+			# type: (str) -> Tuple[str, str, bool]
+			is_client = version_desc.endswith('C')
+			if is_client:
+				version_desc = version_desc[:-1]
+			if version_desc.startswith('d'):
+				return SSH.Product.DropbearSSH, version_desc[1:], is_client
+			elif version_desc.startswith('l1'):
+				return SSH.Product.LibSSH, version_desc[2:], is_client
+			else:
+				return SSH.Product.OpenSSH, version_desc, is_client
+		
+		@classmethod
+		def get_since_text(cls, versions):
+			# type: (List[Optional[str]]) -> Optional[text_type]
+			tv = []
+			if len(versions) == 0 or versions[0] is None:
+				return None
+			for v in versions[0].split(','):
+				ssh_prod, ssh_ver, is_cli = cls.get_ssh_version(v)
+				if not ssh_ver:
+					continue
+				if ssh_prod in [SSH.Product.LibSSH]:
+					continue
+				if is_cli:
+					ssh_ver = '{0} (client only)'.format(ssh_ver)
+				tv.append('{0} {1}'.format(ssh_prod, ssh_ver))
+			if len(tv) == 0:
+				return None
+			return 'available since ' + ', '.join(tv).rstrip(', ')
+	
+	class Algorithms(object):
+		def __init__(self, pkm, kex):
+			# type: (Optional[SSH1.PublicKeyMessage], Optional[SSH2.Kex]) -> None
+			self.__ssh1kex = pkm
+			self.__ssh2kex = kex
+		
+		@property
+		def ssh1kex(self):
+			# type: () -> Optional[SSH1.PublicKeyMessage]
+			return self.__ssh1kex
+		
+		@property
+		def ssh2kex(self):
+			# type: () -> Optional[SSH2.Kex]
+			return self.__ssh2kex
+		
+		@property
+		def ssh1(self):
+			# type: () -> Optional[SSH.Algorithms.Item]
+			if self.ssh1kex is None:
+				return None
+			item = SSH.Algorithms.Item(1, SSH1.KexDB.ALGORITHMS)
+			item.add('key', [u'ssh-rsa1'])
+			item.add('enc', self.ssh1kex.supported_ciphers)
+			item.add('aut', self.ssh1kex.supported_authentications)
+			return item
+		
+		@property
+		def ssh2(self):
+			# type: () -> Optional[SSH.Algorithms.Item]
+			if self.ssh2kex is None:
+				return None
+			item = SSH.Algorithms.Item(2, SSH2.KexDB.ALGORITHMS)
+			item.add('kex', self.ssh2kex.kex_algorithms)
+			item.add('key', self.ssh2kex.key_algorithms)
+			item.add('enc', self.ssh2kex.server.encryption)
+			item.add('mac', self.ssh2kex.server.mac)
+			return item
+		
+		@property
+		def values(self):
+			# type: () -> Iterable[SSH.Algorithms.Item]
+			for item in [self.ssh1, self.ssh2]:
+				if item is not None:
+					yield item
+		
+		@property
+		def maxlen(self):
+			# type: () -> int
+			def _ml(items):
+				# type: (Sequence[text_type]) -> int
+				return max(len(i) for i in items)
+			maxlen = 0
+			if self.ssh1kex is not None:
+				maxlen = max(_ml(self.ssh1kex.supported_ciphers),
+				             _ml(self.ssh1kex.supported_authentications),
+				             maxlen)
+			if self.ssh2kex is not None:
+				maxlen = max(_ml(self.ssh2kex.kex_algorithms),
+				             _ml(self.ssh2kex.key_algorithms),
+				             _ml(self.ssh2kex.server.encryption),
+				             _ml(self.ssh2kex.server.mac),
+				             maxlen)
+			return maxlen
+		
+		def get_ssh_timeframe(self, for_server=None):
+			# type: (Optional[bool]) -> SSH.Algorithm.Timeframe
+			timeframe = SSH.Algorithm.Timeframe()
+			for alg_pair in self.values:
+				alg_db = alg_pair.db
+				for alg_type, alg_list in alg_pair.items():
+					for alg_name in alg_list:
+						alg_name_native = utils.to_ntext(alg_name)
+						alg_desc = alg_db[alg_type].get(alg_name_native)
+						if alg_desc is None:
+							continue
+						versions = alg_desc[0]
+						timeframe.update(versions, for_server)
+			return timeframe
+		
+		def get_recommendations(self, software, for_server=True):
+			# type: (Optional[SSH.Software], bool) -> Tuple[Optional[SSH.Software], Dict[int, Dict[str, Dict[str, Dict[str, int]]]]]
+			# pylint: disable=too-many-locals,too-many-statements
+			vproducts = [SSH.Product.OpenSSH,
+			             SSH.Product.DropbearSSH,
+			             SSH.Product.LibSSH]
+			if software is not None:
+				if software.product not in vproducts:
+					software = None
+			if software is None:
+				ssh_timeframe = self.get_ssh_timeframe(for_server)
+				for product in vproducts:
+					if product not in ssh_timeframe:
+						continue
+					version = ssh_timeframe.get_from(product, for_server)
+					if version is not None:
+						software = SSH.Software(None, product, version, None, None)
+						break
+			rec = {}  # type: Dict[int, Dict[str, Dict[str, Dict[str, int]]]]
+			if software is None:
+				return software, rec
+			for alg_pair in self.values:
+				sshv, alg_db = alg_pair.sshv, alg_pair.db
+				rec[sshv] = {}
+				for alg_type, alg_list in alg_pair.items():
+					if alg_type == 'aut':
+						continue
+					rec[sshv][alg_type] = {'add': {}, 'del': {}, 'chg': {}}
+					for n, alg_desc in alg_db[alg_type].items():
+						versions = alg_desc[0]
+						if len(versions) == 0 or versions[0] is None:
+							continue
+						matches = False
+						for v in versions[0].split(','):
+							ssh_prefix, ssh_version, is_cli = SSH.Algorithm.get_ssh_version(v)
+							if not ssh_version:
+								continue
+							if ssh_prefix != software.product:
+								continue
+							if is_cli and for_server:
+								continue
+							if software.compare_version(ssh_version) < 0:
+								continue
+							matches = True
+							break
+						if not matches:
+							continue
+						adl, faults = len(alg_desc), 0
+						for i in range(1, 3):
+							if not adl > i:
+								continue
+							fc = len(alg_desc[i])
+							if fc > 0:
+								faults += pow(10, 2 - i) * fc
+						if n not in alg_list:
+							if faults > 0 or (alg_type == 'key' and '-cert-' in n):
+								continue
+							rec[sshv][alg_type]['add'][n] = 0
+						else:
+							if faults == 0:
+								continue
+							if n in ['diffie-hellman-group-exchange-sha256', 'ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512', 'ssh-rsa-cert-v01@openssh.com']:
+								rec[sshv][alg_type]['chg'][n] = faults
+							else:
+								rec[sshv][alg_type]['del'][n] = faults
+					add_count = len(rec[sshv][alg_type]['add'])
+					del_count = len(rec[sshv][alg_type]['del'])
+					chg_count = len(rec[sshv][alg_type]['chg'])
+					new_alg_count = len(alg_list) + add_count - del_count
+					if new_alg_count < 1 and del_count > 0:
+						mf = min(rec[sshv][alg_type]['del'].values())
+						new_del = {}
+						for k, cf in rec[sshv][alg_type]['del'].items():
+							if cf != mf:
+								new_del[k] = cf
+						if del_count != len(new_del):
+							rec[sshv][alg_type]['del'] = new_del
+							new_alg_count += del_count - len(new_del)
+					if new_alg_count < 1:
+						del rec[sshv][alg_type]
+					else:
+						if add_count == 0:
+							del rec[sshv][alg_type]['add']
+						if del_count == 0:
+							del rec[sshv][alg_type]['del']
+						if chg_count == 0:
+							del rec[sshv][alg_type]['chg']
+						if len(rec[sshv][alg_type]) == 0:
+							del rec[sshv][alg_type]
+				if len(rec[sshv]) == 0:
+					del rec[sshv]
+			return software, rec
+		
+		class Item(object):
+			def __init__(self, sshv, db):
+				# type: (int, Dict[str, Dict[str, List[List[Optional[str]]]]]) -> None
+				self.__sshv = sshv
+				self.__db = db
+				self.__storage = {}  # type: Dict[str, List[text_type]]
+			
+			@property
+			def sshv(self):
+				# type: () -> int
+				return self.__sshv
+			
+			@property
+			def db(self):
+				# type: () -> Dict[str, Dict[str, List[List[Optional[str]]]]]
+				return self.__db
+			
+			def add(self, key, value):
+				# type: (str, List[text_type]) -> None
+				self.__storage[key] = value
+			
+			def items(self):
+				# type: () -> Iterable[Tuple[str, List[text_type]]]
+				return self.__storage.items()
+	
 	class Security(object):  # pylint: disable=too-few-public-methods
 		# pylint: disable=bad-whitespace
 		CVE = {
 			'Dropbear SSH': [
 				['0.44', '2015.71', 1, 'CVE-2016-3116', 5.5, 'bypass command restrictions via xauth command injection'],
 				['0.28', '2013.58', 1, 'CVE-2013-4434', 5.0, 'discover valid usernames through different time delays'],
-				['0.28', '2013.58', 1, 'CVE-2013-4421', 5.0, 'cause DoS (memory consumption) via a compressed packet'],
+				['0.28', '2013.58', 1, 'CVE-2013-4421', 5.0, 'cause DoS via a compressed packet (memory consumption)'],
 				['0.52', '2011.54', 1, 'CVE-2012-0920', 7.1, 'execute arbitrary code or bypass command restrictions'],
 				['0.40', '0.48.1',  1, 'CVE-2007-1099', 7.5, 'conduct a MitM attack (no warning for hostkey mismatch)'],
-				['0.28', '0.47',    1, 'CVE-2006-1206', 7.5, 'cause DoS (slot exhaustion) via large number of connections'],
+				['0.28', '0.47',    1, 'CVE-2006-1206', 7.5, 'cause DoS via large number of connections (slot exhaustion)'],
 				['0.39', '0.47',    1, 'CVE-2006-0225', 4.6, 'execute arbitrary commands via scp with crafted filenames'],
 				['0.28', '0.46',    1, 'CVE-2005-4178', 6.5, 'execute arbitrary code via buffer overflow vulnerability'],
 				['0.28', '0.42',    1, 'CVE-2004-2486', 7.5, 'execute arbitrary code via DSS verification code']],
@@ -1062,7 +1757,65 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 				['0.4.7', '0.5.2',  1, 'CVE-2012-4562', 7.5, 'cause DoS or execute arbitrary code (overflow check)'],
 				['0.4.7', '0.5.2',  1, 'CVE-2012-4561', 5.0, 'cause DoS via unspecified vectors (invalid pointer)'],
 				['0.4.7', '0.5.2',  1, 'CVE-2012-4560', 7.5, 'cause DoS or execute arbitrary code (buffer overflow)'],
-				['0.4.7', '0.5.2',  1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']]
+				['0.4.7', '0.5.2',  1, 'CVE-2012-4559', 6.8, 'cause DoS or execute arbitrary code (double free)']],
+			'OpenSSH': [
+				['7.2',     '7.2p2',   1, 'CVE-2016-6515',  7.8, 'cause DoS via long password string (crypt CPU consumption)'],
+				['1.2.2',   '7.2',     1, 'CVE-2016-3115',  5.5, 'bypass command restrictions via crafted X11 forwarding data'],
+				['5.4',     '7.1',     1, 'CVE-2016-1907',  5.0, 'cause DoS via crafted network traffic (out of bounds read)'],
+				['5.4',     '7.1p1',   2, 'CVE-2016-0778',  4.6, 'cause DoS via requesting many forwardings (heap based buffer overflow)'],
+				['5.0',     '7.1p1',   2, 'CVE-2016-0777',  4.0, 'leak data via allowing transfer of entire buffer'],
+				['6.0',     '7.2p2',   5, 'CVE-2015-8325',  7.2, 'privilege escalation via triggering crafted environment'],
+				['6.8',     '6.9',     5, 'CVE-2015-6565',  7.2, 'cause DoS via writing to a device (terminal disruption)'],
+				['5.0',     '6.9',     5, 'CVE-2015-6564',  6.9, 'privilege escalation via leveraging sshd uid'],
+				['5.0',     '6.9',     5, 'CVE-2015-6563',  1.9, 'conduct impersonation attack'],
+				['6.9p1',   '6.9p1',   1, 'CVE-2015-5600',  8.5, 'cause Dos or aid in conduct brute force attack (CPU consumption)'],
+				['6.0',     '6.6',     1, 'CVE-2015-5352',  4.3, 'bypass access restrictions via a specific connection'],
+				['6.0',     '6.6',     2, 'CVE-2014-2653',  5.8, 'bypass SSHFP DNS RR check via unacceptable host certificate'],
+				['5.0',     '6.5',     1, 'CVE-2014-2532',  5.8, 'bypass environment restrictions via specific string before wildcard'],
+				['1.2',     '6.4',     1, 'CVE-2014-1692',  7.5, 'cause DoS via triggering error condition (memory corruption)'],
+				['6.2',     '6.3',     1, 'CVE-2013-4548',  6.0, 'bypass command restrictions via crafted packet data'],
+				['1.2',     '5.6',     1, 'CVE-2012-0814',  3.5, 'leak data via debug messages'],
+				['1.2',     '5.8',     1, 'CVE-2011-5000',  3.5, 'cause DoS via large value in certain length field (memory consumption)'],
+				['5.6',     '5.7',     2, 'CVE-2011-0539',  5.0, 'leak data or conduct hash collision attack'],
+				['1.2',     '6.1',     1, 'CVE-2010-5107',  5.0, 'cause DoS via large number of connections (slot exhaustion)'],
+				['1.2',     '5.8',     1, 'CVE-2010-4755',  4.0, 'cause DoS via crafted glob expression (CPU and memory consumption)'],
+				['1.2',     '5.6',     1, 'CVE-2010-4478',  7.5, 'bypass authentication check via crafted values'],
+				['4.3',     '4.8',     1, 'CVE-2009-2904',  6.9, 'privilege escalation via hard links to setuid programs'],
+				['4.0',     '5.1',     1, 'CVE-2008-5161',  2.6, 'recover plaintext data from ciphertext'],
+				['1.2',     '4.6',     1, 'CVE-2008-4109',  5.0, 'cause DoS via multiple login attempts (slot exhaustion)'],
+				['1.2',     '4.8',     1, 'CVE-2008-1657',  6.5, 'bypass command restrictions via modifying session file'],
+				['1.2.2',   '4.9',     1, 'CVE-2008-1483',  6.9, 'hijack forwarded X11 connections'],
+				['4.0',     '4.6',     1, 'CVE-2007-4752',  7.5, 'privilege escalation via causing an X client to be trusted'],
+				['4.3p2',   '4.3p2',   1, 'CVE-2007-3102',  4.3, 'allow attacker to write random data to audit log'],
+				['1.2',     '4.6',     1, 'CVE-2007-2243',  5.0, 'discover valid usernames through different responses'],
+				['4.4',     '4.4',     1, 'CVE-2006-5794',  7.5, 'bypass authentication'],
+				['4.1',     '4.1p1',   1, 'CVE-2006-5229',  2.6, 'discover valid usernames through different time delays'],
+				['1.2',     '4.3p2',   1, 'CVE-2006-5052',  5.0, 'discover valid usernames through different responses'],
+				['1.2',     '4.3p2',   1, 'CVE-2006-5051',  9.3, 'cause DoS or execute arbitrary code (double free)'],
+				['4.5',     '4.5',     1, 'CVE-2006-4925',  5.0, 'cause DoS via invalid protocol sequence (crash)'],
+				['1.2',     '4.3p2',   1, 'CVE-2006-4924',  7.8, 'cause DoS via crafted packet (CPU consumption)'],
+				['3.8.1p1', '3.8.1p1', 1, 'CVE-2006-0883',  5.0, 'cause DoS via connecting multiple times (client connection refusal)'],
+				['3.0',     '4.2p1',   1, 'CVE-2006-0225',  4.6, 'execute arbitrary code'],
+				['2.1',     '4.1p1',   1, 'CVE-2005-2798',  5.0, 'leak data about authentication credentials'],
+				['3.5',     '3.5p1',   1, 'CVE-2004-2760',  6.8, 'leak data through different connection states'],
+				['2.3',     '3.7.1p2', 1, 'CVE-2004-2069',  5.0, 'cause DoS via large number of connections (slot exhaustion)'],
+				['3.0',     '3.4p1',   1, 'CVE-2004-0175',  4.3, 'leak data through directoy traversal'],
+				['1.2',     '3.9p1',   1, 'CVE-2003-1562',  7.6, 'leak data about authentication credentials'],
+				['3.1p1',   '3.7.1p1', 1, 'CVE-2003-0787',  7.5, 'privilege escalation via modifying stack'],
+				['3.1p1',   '3.7.1p1', 1, 'CVE-2003-0786', 10.0, 'privilege escalation via bypassing authentication'],
+				['1.0',     '3.7.1',   1, 'CVE-2003-0695',  7.5, 'cause DoS or execute arbitrary code'],
+				['1.0',     '3.7',     1, 'CVE-2003-0693', 10.0, 'execute arbitrary code'],
+				['3.0',     '3.6.1p2', 1, 'CVE-2003-0386',  7.5, 'bypass address restrictions for connection'],
+				['3.1p1',   '3.6.1p1', 1, 'CVE-2003-0190',  5.0, 'discover valid usernames through different time delays'],
+				['3.2.2',   '3.2.2',   1, 'CVE-2002-0765',  7.5, 'bypass authentication'],
+				['1.2.2',   '3.3p1',   1, 'CVE-2002-0640', 10.0, 'execute arbitrary code'],
+				['1.2.2',   '3.3p1',   1, 'CVE-2002-0639', 10.0, 'execute arbitrary code'],
+				['2.1',     '3.2',     1, 'CVE-2002-0575',  7.5, 'privilege escalation'],
+				['2.1',     '3.0.2p1', 2, 'CVE-2002-0083', 10.0, 'privilege escalation'],
+				['3.0',     '3.0p1',   1, 'CVE-2001-1507',  7.5, 'bypass authentication'],
+				['1.2.3',   '3.0.1p1', 5, 'CVE-2001-0872',  7.2, 'privilege escalation via crafted environment variables'],
+				['1.2.3',   '2.1.1',   1, 'CVE-2001-0361',  4.0, 'recover plaintext from ciphertext'],
+				['1.2',     '2.1',     1, 'CVE-2000-0525', 10.0, 'execute arbitrary code (improper privileges)']]
 		}  # type: Dict[str, List[List[Any]]]
 		TXT = {
 			'Dropbear SSH': [
@@ -1079,29 +1832,36 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 		
 		SM_BANNER_SENT = 1
 		
-		def __init__(self, host, port):
-			# type: (str, int) -> None
+		def __init__(self, host, port, ipvo, timeout):
+			# type: (Optional[str], int) -> None
 			super(SSH.Socket, self).__init__()
+			self.__sock = None  # type: Optional[socket.socket]
 			self.__block_size = 8
 			self.__state = 0
 			self.__header = []  # type: List[text_type]
 			self.__banner = None  # type: Optional[SSH.Banner]
+			if host is None:
+				raise ValueError('undefined host')
+			nport = utils.parse_int(port)
+			if nport < 1 or nport > 65535:
+				raise ValueError('invalid port: {0}'.format(port))
 			self.__host = host
-			self.__port = port
-			self.__sock = None  # type: socket.socket
-		
-		def __enter__(self):
-			# type: () -> SSH.Socket
-			return self
+			self.__port = nport
+			if ipvo is not None:
+				self.__ipvo = ipvo
+			else:
+				self.__ipvo = ()
+			self.__timeout = timeout
+
 		
 		def _resolve(self, ipvo):
 			# type: (Sequence[int]) -> Iterable[Tuple[int, Tuple[Any, ...]]]
-			ipvo = tuple(filter(lambda x: x in (4, 6), utils.unique_seq(ipvo)))
+			ipvo = tuple([x for x in utils.unique_seq(ipvo) if x in (4, 6)])
 			ipvo_len = len(ipvo)
 			prefer_ipvo = ipvo_len > 0
 			prefer_ipv4 = prefer_ipvo and ipvo[0] == 4
-			if len(ipvo) == 1:
-				family = {4: socket.AF_INET, 6: socket.AF_INET6}.get(ipvo[0])
+			if ipvo_len == 1:
+				family = socket.AF_INET if ipvo[0] == 4 else socket.AF_INET6
 			else:
 				family = socket.AF_UNSPEC
 			try:
@@ -1110,23 +1870,22 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 				if prefer_ipvo:
 					r = sorted(r, key=lambda x: x[0], reverse=not prefer_ipv4)
 				check = any(stype == rline[2] for rline in r)
-				for (af, socktype, proto, canonname, addr) in r:
+				for af, socktype, _proto, _canonname, addr in r:
 					if not check or socktype == socket.SOCK_STREAM:
-						yield (af, addr)
+						yield af, addr
 			except socket.error as e:
 				out.fail('[exception] {0}'.format(e))
 				sys.exit(1)
 		
-		def connect(self, ipvo=(), cto=3.0, rto=5.0):
-			# type: (Sequence[int], float, float) -> None
+		def connect(self):
+			# type: () -> None
 			err = None
-			for (af, addr) in self._resolve(ipvo):
+			for af, addr in self._resolve(self.__ipvo):
 				s = None
 				try:
 					s = socket.socket(af, socket.SOCK_STREAM)
-					s.settimeout(cto)
+					s.settimeout(self.__timeout)
 					s.connect(addr)
-					s.settimeout(rto)
 					self.__sock = s
 					return
 				except socket.error as e:
@@ -1141,16 +1900,19 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			sys.exit(1)
 		
 		def get_banner(self, sshv=2):
-			# type: (int) -> Tuple[Optional[SSH.Banner], List[text_type]]
-			banner = 'SSH-{0}-OpenSSH_7.3'.format('1.5' if sshv == 1 else '2.0')
+			# type: (int) -> Tuple[Optional[SSH.Banner], List[text_type], Optional[str]]
+			if self.__sock is None:
+				return self.__banner, self.__header, 'not connected'
+			banner = 'SSH-{0}-OpenSSH_7.4'.format('1.5' if sshv == 1 else '2.0')
 			rto = self.__sock.gettimeout()
 			self.__sock.settimeout(0.7)
 			s, e = self.recv()
 			self.__sock.settimeout(rto)
 			if s < 0:
-				return self.__banner, self.__header
+				return self.__banner, self.__header, e
 			if self.__state < self.SM_BANNER_SENT:
 				self.send_banner(banner)
+			e = None
 			while self.__banner is None:
 				if not s > 0:
 					s, e = self.recv()
@@ -1166,34 +1928,38 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 							continue
 					self.__header.append(line)
 				s = 0
-			return self.__banner, self.__header
+			return self.__banner, self.__header, e
 		
 		def recv(self, size=2048):
 			# type: (int) -> Tuple[int, Optional[str]]
+			if self.__sock is None:
+				return -1, 'not connected'
 			try:
 				data = self.__sock.recv(size)
 			except socket.timeout:
-				return (-1, 'timeout')
+				return -1, 'timed out'
 			except socket.error as e:
 				if e.args[0] in (errno.EAGAIN, errno.EWOULDBLOCK):
-					return (0, 'retry')
-				return (-1, str(e.args[-1]))
+					return 0, 'retry'
+				return -1, str(e.args[-1])
 			if len(data) == 0:
-				return (-1, None)
+				return -1, None
 			pos = self._buf.tell()
 			self._buf.seek(0, 2)
 			self._buf.write(data)
 			self._len += len(data)
 			self._buf.seek(pos, 0)
-			return (len(data), None)
+			return len(data), None
 		
 		def send(self, data):
 			# type: (binary_type) -> Tuple[int, Optional[str]]
+			if self.__sock is None:
+				return -1, 'not connected'
 			try:
 				self.__sock.send(data)
-				return (0, None)
+				return 0, None
 			except socket.error as e:
-				return (-1, str(e.args[-1]))
+				return -1, str(e.args[-1])
 			self.__sock.send(data)
 		
 		def send_banner(self, banner):
@@ -1218,7 +1984,7 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 				header.write_int(packet_length)
 				# XXX: validate length
 				if sshv == 1:
-					padding_length = (8 - packet_length % 8)
+					padding_length = 8 - packet_length % 8
 					self.ensure_read(padding_length)
 					padding = self.read(padding_length)
 					header.write(padding)
@@ -1259,7 +2025,7 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 					e = header.write_flush().strip()
 				else:
 					e = ex.args[0].encode('utf-8')
-				return (-1, e)
+				return -1, e
 		
 		def send_packet(self):
 			# type: () -> Tuple[int, Optional[str]]
@@ -1271,13 +2037,27 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			pad_bytes = b'\x00' * padding
 			data = struct.pack('>Ib', plen, padding) + payload + pad_bytes
 			return self.send(data)
-		
+
+		# Returns True if this Socket is connected, otherwise False.
+		def is_connected(self):
+			return (self.__sock is not None)
+
+		def close(self):
+			self.__cleanup()
+			self.reset()
+			self.__state = 0
+			self.__header = []
+			self.__banner = None
+
+		def reset(self):
+			super(SSH.Socket, self).reset()
+
 		def _close_socket(self, s):
 			# type: (Optional[socket.socket]) -> None
 			try:
 				if s is not None:
 					s.shutdown(socket.SHUT_RDWR)
-					s.close()
+					s.close()  # pragma: nocover
 			except:  # pylint: disable=bare-except
 				pass
 		
@@ -1285,36 +2065,190 @@ class SSH(object):  # pylint: disable=too-few-public-methods
 			# type: () -> None
 			self.__cleanup()
 		
-		def __exit__(self, *args):
-			# type: (*Any) -> None
-			self.__cleanup()
-		
 		def __cleanup(self):
 			# type: () -> None
 			self._close_socket(self.__sock)
+			self.__sock = None
 
 
-class KexDH(object):
-	def __init__(self, alg, g, p):
+class KexDH(object):  # pragma: nocover
+	def __init__(self, kex_name, hash_alg, g, p):
 		# type: (str, int, int) -> None
-		self.__alg = alg
+		self.__kex_name = kex_name
+		self.__hash_alg = hash_alg
+		self.__g = 0
+		self.__p = 0
+		self.__q = 0
+		self.__x = 0
+		self.__e = 0
+		self.set_params(g, p)
+
+		self.__ed25519_pubkey = 0
+		self.__hostkey_type = None
+		self.__hostkey_e = 0
+		self.__hostkey_n = 0
+		self.__hostkey_n_len = 0 # Length of the host key modulus.
+		self.__ca_n_len = 0 # Length of the CA key modulus (if hostkey is a cert).
+		self.__f = 0
+		self.__h_sig = 0
+
+	def set_params(self, g, p):
 		self.__g = g
 		self.__p = p
 		self.__q = (self.__p - 1) // 2
-		self.__x = None  # type: Optional[int]
-		self.__e = None  # type: Optional[int]
-	
-	def send_init(self, s):
+		self.__x = 0
+		self.__e = 0
+
+
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_INIT):
 		# type: (SSH.Socket) -> None
 		r = random.SystemRandom()
 		self.__x = r.randrange(2, self.__q)
 		self.__e = pow(self.__g, self.__x, self.__p)
-		s.write_byte(SSH.Protocol.MSG_KEXDH_INIT)
+		s.write_byte(init_msg)
 		s.write_mpint2(self.__e)
 		s.send_packet()
 
+	# Parse a KEXDH_REPLY or KEXDH_GEX_REPLY message from the server.  This
+	# Contains the host key, among other things.
+	def recv_reply(self, s):
+		packet_type, payload = s.read_packet(2)
+		if packet_type != -1 and packet_type not in [SSH.Protocol.MSG_KEXDH_REPLY, SSH.Protocol.MSG_KEXDH_GEX_REPLY]:
+			# TODO: change Exception to something more specific.
+			raise Exception('Expected MSG_KEXDH_REPLY (%d) or MSG_KEXDH_GEX_REPLY (%d), but got %d instead.' % (SSH.Protocol.MSG_KEXDH_REPLY, SSH.Protocol.MSG_KEXDH_GEX_REPLY, packet_type))
+		elif packet_type == -1:
+			# A connection error occurred.  We can't parse anything, so just
+			# return.  The host key modulus (and perhaps certificate modulus)
+			# will remain at length 0.
+			return
 
-class KexGroup1(KexDH):
+		hostkey_len = f_len = h_sig_len = 0  # pylint: disable=unused-variable
+		hostkey_type_len = hostkey_e_len = 0 # pylint: disable=unused-variable
+		key_id_len = principles_len = 0      # pylint: disable=unused-variable
+		critical_options_len = extensions_len = 0        # pylint: disable=unused-variable
+		nonce_len = ca_key_len = ca_key_type_len = 0     # pylint: disable=unused-variable
+		ca_key_len = ca_key_type_len = ca_key_e_len = 0  # pylint: disable=unused-variable
+
+		key_id = principles = None           # pylint: disable=unused-variable
+		critical_options = extensions = None # pylint: disable=unused-variable
+		valid_after = valid_before = None    # pylint: disable=unused-variable
+		nonce = ca_key = ca_key_type = None  # pylint: disable=unused-variable
+		ca_key_e = ca_key_n = None           # pylint: disable=unused-variable
+
+		# Get the host key blob, F, and signature.
+		ptr = 0
+		hostkey, hostkey_len, ptr = KexDH.__get_bytes(payload, ptr)
+		self.__f, f_len, ptr = KexDH.__get_bytes(payload, ptr)
+		self.__h_sig, h_sig_len, ptr = KexDH.__get_bytes(payload, ptr)
+
+		# Now pick apart the host key blob.
+		# Get the host key type (i.e.: 'ssh-rsa', 'ssh-ed25519', etc).
+		ptr = 0
+		self.__hostkey_type, hostkey_type_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+		# If this is an RSA certificate, skip over the nonce.
+		if self.__hostkey_type.startswith(b'ssh-rsa-cert-v0'):
+			nonce, nonce_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+		# The public key exponent.
+		hostkey_e, hostkey_e_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+		self.__hostkey_e = int(binascii.hexlify(hostkey_e), 16)
+
+		# Here is the modulus size & actual modulus of the host key public key.
+		hostkey_n, self.__hostkey_n_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+		self.__hostkey_n = int(binascii.hexlify(hostkey_n), 16)
+
+		# If this is an RSA certificate, continue parsing to extract the CA
+		# key.
+		if self.__hostkey_type.startswith(b'ssh-rsa-cert-v0'):
+			# Skip over the serial number.
+			ptr += 8
+
+			# Get the certificate type.
+			cert_type = int(binascii.hexlify(hostkey[ptr:ptr + 4]), 16)
+			ptr += 4
+
+			# Only SSH2_CERT_TYPE_HOST (2) makes sense in this context.
+			if cert_type == 2:
+
+				# Skip the key ID (this is the serial number of the
+				# certificate).
+				key_id, key_id_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# The principles, which are... I don't know what.
+				principles, principles_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# The timestamp that this certificate is valid after.
+				valid_after = hostkey[ptr:ptr + 8]
+				ptr += 8
+
+				# The timestamp that this certificate is valid before.
+				valid_before = hostkey[ptr:ptr + 8]
+				ptr += 8
+
+				# TODO: validate the principles, and time range.
+
+				# The critical options.
+				critical_options, critical_options_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# Certificate extensions.
+				extensions, extensions_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# Another nonce.
+				nonce, nonce_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# Finally, we get to the CA key.
+				ca_key, ca_key_len, ptr = KexDH.__get_bytes(hostkey, ptr)
+
+				# Last in the host key blob is the CA signature.  It isn't
+				# interesting to us, so we won't bother parsing any further.
+				# The CA key has the modulus, however...
+				ptr = 0
+
+				# 'ssh-rsa', 'rsa-sha2-256', etc.
+				ca_key_type, ca_key_type_len, ptr = KexDH.__get_bytes(ca_key, ptr)
+
+				# CA's public key exponent.
+				ca_key_e, ca_key_e_len, ptr = KexDH.__get_bytes(ca_key, ptr)
+
+				# CA's modulus.  Bingo.
+				ca_key_n, self.__ca_n_len, ptr = KexDH.__get_bytes(ca_key, ptr)
+
+
+	@staticmethod
+	def __get_bytes(buf, ptr):
+		num_bytes = struct.unpack('>I', buf[ptr:ptr + 4])[0]
+		ptr += 4
+		return buf[ptr:ptr + num_bytes], num_bytes, ptr + num_bytes
+
+	# Converts a modulus length in bytes to its size in bits, after some
+	# possible adjustments.
+	@staticmethod
+	def __adjust_key_size(size):
+		size = size * 8
+		# Actual keys are observed to be about 8 bits bigger than expected
+		# (i.e.: 1024-bit keys have a 1032-bit modulus).  Check if this is
+		# the case, and subtract 8 if so.  This simply improves readability
+		# in the UI.
+		if (size >> 3) % 2 != 0:
+			size = size - 8
+		return size
+
+	# Returns the size of the hostkey, in bits.
+	def get_hostkey_size(self):
+		return KexDH.__adjust_key_size(self.__hostkey_n_len)
+
+	# Returns the size of the CA key, in bits.
+	def get_ca_size(self):
+		return KexDH.__adjust_key_size(self.__ca_n_len)
+
+	# Returns the size of the DH modulus, in bits.
+	def get_dh_modulus_size(self):
+		# -2 to account for the '0b' prefix in the string.
+		return len(bin(self.__p)) - 2
+
+
+class KexGroup1(KexDH):  # pragma: nocover
 	def __init__(self):
 		# type: () -> None
 		# rfc2409: second oakley group
@@ -1323,11 +2257,11 @@ class KexGroup1(KexDH):
 		        'f25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff'
 		        '5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381'
 		        'ffffffffffffffff', 16)
-		super(KexGroup1, self).__init__('sha1', 2, p)
+		super(KexGroup1, self).__init__('KexGroup1', 'sha1', 2, p)
 
 
-class KexGroup14(KexDH):
-	def __init__(self):
+class KexGroup14(KexDH):  # pragma: nocover
+	def __init__(self, hash_alg):
 		# type: () -> None
 		# rfc3526: 2048-bit modp group
 		p = int('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67'
@@ -1339,334 +2273,221 @@ class KexGroup14(KexDH):
 		        'ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c5'
 		        '5df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa0510'
 		        '15728e5a8aacaa68ffffffffffffffff', 16)
-		super(KexGroup14, self).__init__('sha1', 2, p)
+		super(KexGroup14, self).__init__('KexGroup14', hash_alg, 2, p)
 
 
-class KexDB(object):  # pylint: disable=too-few-public-methods
-	# pylint: disable=bad-whitespace
-	WARN_OPENSSH72_LEGACY = 'disabled (in client) since OpenSSH 7.2, legacy algorithm'
-	FAIL_OPENSSH70_LEGACY = 'removed since OpenSSH 7.0, legacy algorithm'
-	FAIL_OPENSSH70_WEAK   = 'removed (in server) and disabled (in client) since OpenSSH 7.0, weak algorithm'
-	FAIL_OPENSSH70_LOGJAM = 'disabled (in client) since OpenSSH 7.0, logjam attack'
-	INFO_OPENSSH69_CHACHA = 'default cipher since OpenSSH 6.9.'
-	FAIL_OPENSSH67_UNSAFE = 'removed (in server) since OpenSSH 6.7, unsafe algorithm'
-	FAIL_OPENSSH61_REMOVE = 'removed since OpenSSH 6.1, removed from specification'
-	FAIL_OPENSSH31_REMOVE = 'removed since OpenSSH 3.1'
-	FAIL_DBEAR67_DISABLED = 'disabled since Dropbear SSH 2015.67'
-	FAIL_DBEAR53_DISABLED = 'disabled since Dropbear SSH 0.53'
-	FAIL_PLAINTEXT        = 'no encryption/integrity'
-	WARN_CURVES_WEAK      = 'using weak elliptic curves'
-	WARN_RNDSIG_KEY       = 'using weak random number generator could reveal the key'
-	WARN_MODULUS_SIZE     = 'using small 1024-bit modulus'
-	WARN_MODULUS_CUSTOM   = 'using custom size modulus (possibly weak)'
-	WARN_HASH_WEAK        = 'using weak hashing algorithm'
-	WARN_CIPHER_MODE      = 'using weak cipher mode'
-	WARN_BLOCK_SIZE       = 'using small 64-bit block size'
-	WARN_CIPHER_WEAK      = 'using weak cipher'
-	WARN_ENCRYPT_AND_MAC  = 'using encrypt-and-MAC mode'
-	WARN_TAG_SIZE         = 'using small 64-bit tag size'
-
-	ALGORITHMS = {
-		'kex': {
-			'diffie-hellman-group1-sha1': [['2.3.0,d0.28,l10.2', '6.6', '6.9'], [FAIL_OPENSSH67_UNSAFE, FAIL_OPENSSH70_LOGJAM], [WARN_MODULUS_SIZE, WARN_HASH_WEAK]],
-			'diffie-hellman-group14-sha1': [['3.9,d0.53,l10.6.0'], [], [WARN_HASH_WEAK]],
-			'diffie-hellman-group14-sha256': [['7.3,d2016.73']],
-			'diffie-hellman-group16-sha512': [['7.3,d2016.73']],
-			'diffie-hellman-group18-sha512': [['7.3']],
-			'diffie-hellman-group-exchange-sha1': [['2.3.0', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]],
-			'diffie-hellman-group-exchange-sha256': [['4.4'], [], [WARN_MODULUS_CUSTOM]],
-			'ecdh-sha2-nistp256': [['5.7,d2013.62,l10.6.0'], [WARN_CURVES_WEAK]],
-			'ecdh-sha2-nistp384': [['5.7,d2013.62'], [WARN_CURVES_WEAK]],
-			'ecdh-sha2-nistp521': [['5.7,d2013.62'], [WARN_CURVES_WEAK]],
-			'curve25519-sha256@libssh.org': [['6.5,d2013.62,l10.6.0']],
-			'kexguess2@matt.ucc.asn.au': [['d2013.57']],
-		},
-		'key': {
-			'rsa-sha2-256': [['7.2']],
-			'rsa-sha2-512': [['7.2']],
-			'ssh-ed25519': [['6.5,l10.7.0']],
-			'ssh-ed25519-cert-v01@openssh.com': [['6.5']],
-			'ssh-rsa': [['2.5.0,d0.28,l10.2']],
-			'ssh-dss': [['2.1.0,d0.28,l10.2', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp256': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp384': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp521': [['5.7,d2013.62,l10.6.4'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-			'ssh-rsa-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], []],
-			'ssh-dss-cert-v00@openssh.com': [['5.4', '6.9'], [FAIL_OPENSSH70_LEGACY], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
-			'ssh-rsa-cert-v01@openssh.com': [['5.6']],
-			'ssh-dss-cert-v01@openssh.com': [['5.6', '6.9'], [FAIL_OPENSSH70_WEAK], [WARN_MODULUS_SIZE, WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp256-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp384-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-			'ecdsa-sha2-nistp521-cert-v01@openssh.com': [['5.7'], [WARN_CURVES_WEAK], [WARN_RNDSIG_KEY]],
-		},
-		'enc': {
-			'none': [['1.2.2,d2013.56,l10.2'], [FAIL_PLAINTEXT]],
-			'3des-cbc': [['1.2.2,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_WEAK, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
-			'3des-ctr': [['d0.52']],
-			'blowfish-cbc': [['1.2.2,d0.28,l10.2', '6.6,d0.52', '7.1,d0.52'], [FAIL_OPENSSH67_UNSAFE, FAIL_DBEAR53_DISABLED], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
-			'twofish-cbc': [['d0.28', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
-			'twofish128-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
-			'twofish256-cbc': [['d0.47', 'd2014.66'], [FAIL_DBEAR67_DISABLED], [WARN_CIPHER_MODE]],
-			'twofish128-ctr': [['d2015.68']],
-			'twofish256-ctr': [['d2015.68']],
-			'cast128-cbc': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE, WARN_BLOCK_SIZE]],
-			'arcfour': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
-			'arcfour128': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
-			'arcfour256': [['4.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_WEAK]],
-			'aes128-cbc': [['2.3.0,d0.28,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
-			'aes192-cbc': [['2.3.0,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
-			'aes256-cbc': [['2.3.0,d0.47,l10.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_CIPHER_MODE]],
-			'rijndael128-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
-			'rijndael192-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
-			'rijndael256-cbc': [['2.3.0', '3.0.2'], [FAIL_OPENSSH31_REMOVE], [WARN_CIPHER_MODE]],
-			'rijndael-cbc@lysator.liu.se': [['2.3.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_CIPHER_MODE]],
-			'aes128-ctr': [['3.7,d0.52,l10.4.1']],
-			'aes192-ctr': [['3.7,l10.4.1']],
-			'aes256-ctr': [['3.7,d0.52,l10.4.1']],
-			'aes128-gcm@openssh.com': [['6.2']],
-			'aes256-gcm@openssh.com': [['6.2']],
-			'chacha20-poly1305@openssh.com': [['6.5'], [], [], [INFO_OPENSSH69_CHACHA]],
-		},
-		'mac': {
-			'none': [['d2013.56'], [FAIL_PLAINTEXT]],
-			'hmac-sha1': [['2.1.0,d0.28,l10.2'], [], [WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
-			'hmac-sha1-96': [['2.5.0,d0.47', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
-			'hmac-sha2-256': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]],
-			'hmac-sha2-256-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]],
-			'hmac-sha2-512': [['5.9,d2013.56,l10.7.0'], [], [WARN_ENCRYPT_AND_MAC]],
-			'hmac-sha2-512-96': [['5.9', '6.0'], [FAIL_OPENSSH61_REMOVE], [WARN_ENCRYPT_AND_MAC]],
-			'hmac-md5': [['2.1.0,d0.28', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
-			'hmac-md5-96': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC, WARN_HASH_WEAK]],
-			'hmac-ripemd160': [['2.5.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]],
-			'hmac-ripemd160@openssh.com': [['2.1.0', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_ENCRYPT_AND_MAC]],
-			'umac-64@openssh.com': [['4.7'], [], [WARN_ENCRYPT_AND_MAC, WARN_TAG_SIZE]],
-			'umac-128@openssh.com': [['6.2'], [], [WARN_ENCRYPT_AND_MAC]],
-			'hmac-sha1-etm@openssh.com': [['6.2'], [], [WARN_HASH_WEAK]],
-			'hmac-sha1-96-etm@openssh.com': [['6.2', '6.6', None], [FAIL_OPENSSH67_UNSAFE], [WARN_HASH_WEAK]],
-			'hmac-sha2-256-etm@openssh.com': [['6.2']],
-			'hmac-sha2-512-etm@openssh.com': [['6.2']],
-			'hmac-md5-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]],
-			'hmac-md5-96-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY, WARN_HASH_WEAK]],
-			'hmac-ripemd160-etm@openssh.com': [['6.2', '6.6', '7.1'], [FAIL_OPENSSH67_UNSAFE], [WARN_OPENSSH72_LEGACY]],
-			'umac-64-etm@openssh.com': [['6.2'], [], [WARN_TAG_SIZE]],
-			'umac-128-etm@openssh.com': [['6.2']],
-		}
-	}  # type: Dict[str, Dict[str, List[List[str]]]]
+class KexGroup14_SHA1(KexGroup14):
+	def __init__(self):
+		super(KexGroup14_SHA1, self).__init__('sha1')
 
 
-def get_ssh_version(version_desc):
-	# type: (str) -> Tuple[str, str]
-	if version_desc.startswith('d'):
-		return (SSH.Product.DropbearSSH, version_desc[1:])
-	elif version_desc.startswith('l1'):
-		return (SSH.Product.LibSSH, version_desc[2:])
-	else:
-		return (SSH.Product.OpenSSH, version_desc)
+class KexGroup14_SHA256(KexGroup14):
+	def __init__(self):
+		super(KexGroup14_SHA256, self).__init__('sha256')
 
 
-def get_alg_timeframe(versions, for_server=True, result=None):
-	# type: (List[str], bool, Optional[Dict[str, List[Optional[str]]]]) -> Dict[str, List[Optional[str]]]
-	result = result or {}
-	vlen = len(versions)
-	for i in range(3):
-		if i > vlen - 1:
-			if i == 2 and vlen > 1:
-				cversions = versions[1]
-			else:
-				continue
-		else:
-			cversions = versions[i]
-		if cversions is None:
-			continue
-		for v in cversions.split(','):
-			ssh_prefix, ssh_version = get_ssh_version(v)
-			if not ssh_version:
-				continue
-			if ssh_version.endswith('C'):
-				if for_server:
-					continue
-				ssh_version = ssh_version[:-1]
-			if ssh_prefix not in result:
-				result[ssh_prefix] = [None, None, None]
-			prev, push = result[ssh_prefix][i], False
-			if prev is None:
-				push = True
-			elif i == 0 and prev < ssh_version:
-				push = True
-			elif i > 0 and prev > ssh_version:
-				push = True
-			if push:
-				result[ssh_prefix][i] = ssh_version
-	return result
+class KexGroup16_SHA512(KexDH):
+	def __init__(self):
+		# rfc3526: 4096-bit modp group
+		p = int('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67'
+		        'cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6d'
+				'f25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff'
+				'5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3d'
+				'c2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3'
+				'ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08'
+				'ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c5'
+				'5df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa0510'
+				'15728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458db'
+				'ef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e0'
+				'4a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f'
+				'2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab31'
+				'43db5bfce0fd108e4b82d120a92108011a723c12a787e6d788719a10bdba'
+				'5b2699c327186af4e23c1a946834b6150bda2583e9ca2ad44ce8dbbbc2db'
+				'04de8ef92e8efc141fbecaa6287c59474e6bc05d99b2964fa090c3a2233b'
+				'a186515be7ed1f612970cee2d7afb81bdd762170481cd0069127d5b05aa9'
+				'93b4ea988d8fddc186ffb7dc90a6c08f4df435c934063199ffffffffffff'
+				'ffff', 16)
+		super(KexGroup16_SHA512, self).__init__('KexGroup16_SHA512', 'sha512', 2, p)
 
 
-def get_ssh_timeframe(alg_pairs, for_server=True):
-	# type: (List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]], bool) -> Dict[str, List[Optional[str]]]
-	timeframe = {}  # type: Dict[str, List[Optional[str]]]
-	for alg_pair in alg_pairs:
-		alg_db = alg_pair[1]
-		for alg_set in alg_pair[2]:
-			alg_type, alg_list = alg_set
-			for alg_name in alg_list:
-				alg_name_native = utils.to_ntext(alg_name)
-				alg_desc = alg_db[alg_type].get(alg_name_native)
-				if alg_desc is None:
-					continue
-				versions = alg_desc[0]
-				timeframe = get_alg_timeframe(versions, for_server, timeframe)
-	return timeframe
+class KexGroup18_SHA512(KexDH):
+	def __init__(self):
+		# rfc3526: 8192-bit modp group
+		p = int('ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67'
+				'cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6d'
+				'f25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff'
+				'5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3d'
+				'c2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3'
+				'ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08'
+				'ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c5'
+				'5df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa0510'
+				'15728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458db'
+				'ef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e0'
+				'4a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f'
+				'2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab31'
+				'43db5bfce0fd108e4b82d120a92108011a723c12a787e6d788719a10bdba'
+				'5b2699c327186af4e23c1a946834b6150bda2583e9ca2ad44ce8dbbbc2db'
+				'04de8ef92e8efc141fbecaa6287c59474e6bc05d99b2964fa090c3a2233b'
+				'a186515be7ed1f612970cee2d7afb81bdd762170481cd0069127d5b05aa9'
+				'93b4ea988d8fddc186ffb7dc90a6c08f4df435c93402849236c3fab4d27c'
+				'7026c1d4dcb2602646dec9751e763dba37bdf8ff9406ad9e530ee5db382f'
+				'413001aeb06a53ed9027d831179727b0865a8918da3edbebcf9b14ed44ce'
+				'6cbaced4bb1bdb7f1447e6cc254b332051512bd7af426fb8f401378cd2bf'
+				'5983ca01c64b92ecf032ea15d1721d03f482d7ce6e74fef6d55e702f4698'
+				'0c82b5a84031900b1c9e59e7c97fbec7e8f323a97a7e36cc88be0f1d45b7'
+				'ff585ac54bd407b22b4154aacc8f6d7ebf48e1d814cc5ed20f8037e0a797'
+				'15eef29be32806a1d58bb7c5da76f550aa3d8a1fbff0eb19ccb1a313d55c'
+				'da56c9ec2ef29632387fe8d76e3c0468043e8f663f4860ee12bf2d5b0b74'
+				'74d6e694f91e6dbe115974a3926f12fee5e438777cb6a932df8cd8bec4d0'
+				'73b931ba3bc832b68d9dd300741fa7bf8afc47ed2576f6936ba424663aab'
+				'639c5ae4f5683423b4742bf1c978238f16cbe39d652de3fdb8befc848ad9'
+				'22222e04a4037c0713eb57a81a23f0c73473fc646cea306b4bcbc8862f83'
+				'85ddfa9d4b7fa2c087e879683303ed5bdd3a062b3cf5b3a278a66d2a13f8'
+				'3f44f82ddf310ee074ab6a364597e899a0255dc164f31cc50846851df9ab'
+				'48195ded7ea1b1d510bd7ee74d73faf36bc31ecfa268359046f4eb879f92'
+				'4009438b481c6cd7889a002ed5ee382bc9190da6fc026e479558e4475677'
+				'e9aa9e3050e2765694dfc81f56e880b96e7160c980dd98edd3dfffffffff'
+				'ffffffff', 16)
+		super(KexGroup18_SHA512, self).__init__('KexGroup18_SHA512', 'sha512', 2, p)
 
 
-def get_alg_since_text(versions):
-	# type: (List[str]) -> text_type
-	tv = []
-	if len(versions) == 0 or versions[0] is None:
-		return None
-	for v in versions[0].split(','):
-		ssh_prefix, ssh_version = get_ssh_version(v)
-		if not ssh_version:
-			continue
-		if ssh_prefix in [SSH.Product.LibSSH]:
-			continue
-		if ssh_version.endswith('C'):
-			ssh_version = '{0} (client only)'.format(ssh_version[:-1])
-		tv.append('{0} {1}'.format(ssh_prefix, ssh_version))
-	if len(tv) == 0:
-		return None
-	return 'available since ' + ', '.join(tv).rstrip(', ')
+class KexCurve25519_SHA256(KexDH):
+	def __init__(self):
+		super(KexCurve25519_SHA256, self).__init__('KexCurve25519_SHA256', 'sha256', 0, 0)
+
+	# To start an ED25519 kex, we simply send a random 256-bit number as the
+	# public key.
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_INIT):
+		self.__ed25519_pubkey = os.urandom(32)
+		s.write_byte(init_msg)
+		s.write_string(self.__ed25519_pubkey)
+		s.send_packet()
 
 
-def get_alg_pairs(kex, pkm):
-	# type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> List[Tuple[int, Dict[str, Dict[str, List[List[str]]]], List[Tuple[str, List[text_type]]]]]
-	alg_pairs = []
-	if pkm is not None:
-		alg_pairs.append((1, SSH1.KexDB.ALGORITHMS,
-		                  [('key', [u'ssh-rsa1']),
-		                   ('enc', pkm.supported_ciphers),
-		                   ('aut', pkm.supported_authentications)]))
-	if kex is not None:
-		alg_pairs.append((2, KexDB.ALGORITHMS,
-		                  [('kex', kex.kex_algorithms),
-		                   ('key', kex.key_algorithms),
-		                   ('enc', kex.server.encryption),
-		                   ('mac', kex.server.mac)]))
-	return alg_pairs
+class KexNISTP256(KexDH):
+	def __init__(self):
+		super(KexNISTP256, self).__init__('KexNISTP256', 'sha256', 0, 0)
+
+	# Because the server checks that the value sent here is valid (i.e.: it lies
+	# on the curve, among other things), we would have to write a lot of code
+	# or import an elliptic curve library in order to randomly generate a
+	# valid elliptic point each time.  Hence, we will simply send a static
+	# value, which is enough for us to extract the server's host key.
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_INIT):
+		s.write_byte(init_msg)
+		s.write_string(b'\x04\x0b\x60\x44\x9f\x8a\x11\x9e\xc7\x81\x0c\xa9\x98\xfc\xb7\x90\xaa\x6b\x26\x8c\x12\x4a\xc0\x09\xbb\xdf\xc4\x2c\x4c\x2c\x99\xb6\xe1\x71\xa0\xd4\xb3\x62\x47\x74\xb3\x39\x0c\xf2\x88\x4a\x84\x6b\x3b\x15\x77\xa5\x77\xd2\xa9\xc9\x94\xf9\xd5\x66\x19\xcd\x02\x34\xd1')
+		s.send_packet()
 
 
-def get_alg_recommendations(software, kex, pkm, for_server=True):
-	# type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, bool) -> Tuple[SSH.Software, Dict[int, Dict[str, Dict[str, Dict[str, int]]]]]
-	# pylint: disable=too-many-locals,too-many-statements
-	alg_pairs = get_alg_pairs(kex, pkm)
-	vproducts = [SSH.Product.OpenSSH,
-	             SSH.Product.DropbearSSH,
-	             SSH.Product.LibSSH]
-	if software is not None:
-		if software.product not in vproducts:
-			software = None
-	if software is None:
-		ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server)
-		for product in vproducts:
-			if product not in ssh_timeframe:
-				continue
-			version = ssh_timeframe[product][0]
-			if version is not None:
-				software = SSH.Software(None, product, version, None, None)
-				break
-	rec = {}  # type: Dict[int, Dict[str, Dict[str, Dict[str, int]]]]
-	if software is None:
-		return software, rec
-	for alg_pair in alg_pairs:
-		sshv, alg_db = alg_pair[0], alg_pair[1]
-		rec[sshv] = {}
-		for alg_set in alg_pair[2]:
-			alg_type, alg_list = alg_set
-			if alg_type == 'aut':
-				continue
-			rec[sshv][alg_type] = {'add': {}, 'del': {}}
-			for n, alg_desc in alg_db[alg_type].items():
-				if alg_type == 'key' and '-cert-' in n:
-					continue
-				versions = alg_desc[0]
-				if len(versions) == 0 or versions[0] is None:
-					continue
-				matches = False
-				for v in versions[0].split(','):
-					ssh_prefix, ssh_version = get_ssh_version(v)
-					if not ssh_version:
-						continue
-					if ssh_prefix != software.product:
-						continue
-					if ssh_version.endswith('C'):
-						if for_server:
-							continue
-						ssh_version = ssh_version[:-1]
-					if software.compare_version(ssh_version) < 0:
-						continue
-					matches = True
-					break
-				if not matches:
-					continue
-				adl, faults = len(alg_desc), 0
-				for i in range(1, 3):
-					if not adl > i:
-						continue
-					fc = len(alg_desc[i])
-					if fc > 0:
-						faults += pow(10, 2 - i) * fc
-				if n not in alg_list:
-					if faults > 0:
-						continue
-					rec[sshv][alg_type]['add'][n] = 0
-				else:
-					if faults == 0:
-						continue
-					if n == 'diffie-hellman-group-exchange-sha256':
-						if software.compare_version('7.3') < 0:
-							continue
-					rec[sshv][alg_type]['del'][n] = faults
-			add_count = len(rec[sshv][alg_type]['add'])
-			del_count = len(rec[sshv][alg_type]['del'])
-			new_alg_count = len(alg_list) + add_count - del_count
-			if new_alg_count < 1 and del_count > 0:
-				mf = min(rec[sshv][alg_type]['del'].values())
-				new_del = {}
-				for k, cf in rec[sshv][alg_type]['del'].items():
-					if cf != mf:
-						new_del[k] = cf
-				if del_count != len(new_del):
-					rec[sshv][alg_type]['del'] = new_del
-					new_alg_count += del_count - len(new_del)
-			if new_alg_count < 1:
-				del rec[sshv][alg_type]
-			else:
-				if add_count == 0:
-					del rec[sshv][alg_type]['add']
-				if del_count == 0:
-					del rec[sshv][alg_type]['del']
-				if len(rec[sshv][alg_type]) == 0:
-					del rec[sshv][alg_type]
-		if len(rec[sshv]) == 0:
-			del rec[sshv]
-	return software, rec
+class KexNISTP384(KexDH):
+	def __init__(self):
+		super(KexNISTP384, self).__init__('KexNISTP384', 'sha256', 0, 0)
+
+	# See comment for KexNISTP256.send_init().
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_INIT):
+		s.write_byte(init_msg)
+		s.write_string(b'\x04\xe2\x9b\x84\xce\xa1\x39\x50\xfe\x1e\xa3\x18\x70\x1c\xe2\x7a\xe4\xb5\x6f\xdf\x93\x9f\xd4\xf4\x08\xcc\x9b\x02\x10\xa4\xca\x77\x9c\x2e\x51\x44\x1d\x50\x7a\x65\x4e\x7e\x2f\x10\x2d\x2d\x4a\x32\xc9\x8e\x18\x75\x90\x6c\x19\x10\xda\xcc\xa8\xe9\xf4\xc4\x3a\x53\x80\x35\xf4\x97\x9c\x04\x16\xf9\x5a\xdc\xcc\x05\x94\x29\xfa\xc4\xd6\x87\x4e\x13\x21\xdb\x3d\x12\xac\xbd\x20\x3b\x60\xff\xe6\x58\x42')
+		s.send_packet()
 
 
-def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0):
-	# type: (str, Dict[str, Dict[str, List[List[str]]]], str, List[text_type], int) -> None
+class KexNISTP521(KexDH):
+	def __init__(self):
+		super(KexNISTP521, self).__init__('KexNISTP521', 'sha256', 0, 0)
+
+	# See comment for KexNISTP256.send_init().
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_INIT):
+		s.write_byte(init_msg)
+		s.write_string(b'\x04\x01\x02\x90\x29\xe9\x8f\xa8\x04\xaf\x1c\x00\xf9\xc6\x29\xc0\x39\x74\x8e\xea\x47\x7e\x7c\xf7\x15\x6e\x43\x3b\x59\x13\x53\x43\xb0\xae\x0b\xe7\xe6\x7c\x55\x73\x52\xa5\x2a\xc1\x42\xde\xfc\xf4\x1f\x8b\x5a\x8d\xfa\xcd\x0a\x65\x77\xa8\xce\x68\xd2\xc6\x26\xb5\x3f\xee\x4b\x01\x7b\xd2\x96\x23\x69\x53\xc7\x01\xe1\x0d\x39\xe9\x87\x49\x3b\xc8\xec\xda\x0c\xf9\xca\xad\x89\x42\x36\x6f\x93\x78\x78\x31\x55\x51\x09\x51\xc0\x96\xd7\xea\x61\xbf\xc2\x44\x08\x80\x43\xed\xc6\xbb\xfb\x94\xbd\xf8\xdf\x2b\xd8\x0b\x2e\x29\x1b\x8c\xc4\x8a\x04\x2d\x3a')
+		s.send_packet()
+
+
+class KexGroupExchange(KexDH):
+	def __init__(self, classname, hash_alg):
+		super(KexGroupExchange, self).__init__(classname, hash_alg, 0, 0)
+
+	def send_init(self, s, init_msg=SSH.Protocol.MSG_KEXDH_GEX_REQUEST):
+		self.send_init_gex(s)
+
+	# The group exchange starts with sending a message to the server with
+	# the minimum, maximum, and preferred number of bits are for the DH group.
+	# The server responds with a generator and prime modulus that matches that,
+	# then the handshake continues on like a normal DH handshake (except the
+	# SSH message types differ).
+	def send_init_gex(self, s, minbits=1024, prefbits=2048, maxbits=8192):
+
+		# Send the initial group exchange request.  Tell the server what range
+		# of modulus sizes we will accept, along with our preference.
+		s.write_byte(SSH.Protocol.MSG_KEXDH_GEX_REQUEST)
+		s.write_int(minbits)
+		s.write_int(prefbits)
+		s.write_int(maxbits)
+		s.send_packet()
+
+		packet_type, payload = s.read_packet(2)
+		if packet_type != SSH.Protocol.MSG_KEXDH_GEX_GROUP:
+			# TODO: replace with a better exception type.
+			raise Exception('Expected MSG_KEXDH_GEX_REPLY (%d), but got %d instead.' % (SSH.Protocol.MSG_KEXDH_GEX_REPLY, packet_type))
+
+		# Parse the modulus (p) and generator (g) values from the server.
+		ptr = 0
+		p_len = struct.unpack('>I', payload[ptr:ptr + 4])[0]
+		ptr += 4
+
+		p = int(binascii.hexlify(payload[ptr:ptr + p_len]), 16)
+		ptr += p_len
+
+		g_len = struct.unpack('>I', payload[ptr:ptr + 4])[0]
+		ptr += 4
+
+		g = int(binascii.hexlify(payload[ptr:ptr + g_len]), 16)
+		ptr += g_len
+
+		# Now that we got the generator and modulus, perform the DH exchange
+		# like usual.
+		super(KexGroupExchange, self).set_params(g, p)
+		super(KexGroupExchange, self).send_init(s, SSH.Protocol.MSG_KEXDH_GEX_INIT)
+
+
+class KexGroupExchange_SHA1(KexGroupExchange):
+	def __init__(self):
+		super(KexGroupExchange_SHA1, self).__init__('KexGroupExchange_SHA1', 'sha1')
+
+
+class KexGroupExchange_SHA256(KexGroupExchange):
+	def __init__(self):
+		super(KexGroupExchange_SHA256, self).__init__('KexGroupExchange_SHA256', 'sha256')
+
+
+def output_algorithms(title, alg_db, alg_type, algorithms, maxlen=0, alg_sizes=None):
+	# type: (str, Dict[str, Dict[str, List[List[Optional[str]]]]], str, List[text_type], int) -> None
 	with OutputBuffer() as obuf:
 		for algorithm in algorithms:
-			output_algorithm(alg_db, alg_type, algorithm, maxlen)
+			output_algorithm(alg_db, alg_type, algorithm, maxlen, alg_sizes)
 	if len(obuf) > 0:
 		out.head('# ' + title)
 		obuf.flush()
 		out.sep()
 
 
-def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0):
-	# type: (Dict[str, Dict[str, List[List[str]]]], str, text_type, int) -> None
+def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0, alg_sizes=None):
+	# type: (Dict[str, Dict[str, List[List[Optional[str]]]]], str, text_type, int) -> None
 	prefix = '(' + alg_type + ') '
 	if alg_max_len == 0:
 		alg_max_len = len(alg_name)
 	padding = '' if out.batch else ' ' * (alg_max_len - len(alg_name))
+
+	# If this is an RSA host key or DH GEX, append the size to its name and fix
+	# the padding.
+	alg_name_with_size = None
+	if (alg_sizes is not None) and (alg_name in alg_sizes):
+		hostkey_size, ca_size = alg_sizes[alg_name]
+		if ca_size > 0:
+			alg_name_with_size = '%s (%d-bit cert/%d-bit CA)' % (alg_name, hostkey_size, ca_size)
+			padding = padding[0:-15]
+		else:
+			alg_name_with_size = '%s (%d-bit)' % (alg_name, hostkey_size)
+			padding = padding[0:-11]
+
 	texts = []
 	if len(alg_name.strip()) == 0:
 		return
@@ -1677,59 +2498,66 @@ def output_algorithm(alg_db, alg_type, alg_name, alg_max_len=0):
 		for idx, level in enumerate(['fail', 'warn', 'info']):
 			if level == 'info':
 				versions = alg_desc[0]
-				since_text = get_alg_since_text(versions)
-				if since_text:
+				since_text = SSH.Algorithm.get_since_text(versions)
+				if since_text is not None and len(since_text) > 0:
 					texts.append((level, since_text))
 			idx = idx + 1
 			if ldesc > idx:
 				for t in alg_desc[idx]:
+					if t is None:
+						continue
 					texts.append((level, t))
 		if len(texts) == 0:
 			texts.append(('info', ''))
 	else:
 		texts.append(('warn', 'unknown algorithm'))
+
+	alg_name = alg_name_with_size if alg_name_with_size is not None else alg_name
 	first = True
-	for (level, text) in texts:
+	for level, text in texts:
 		f = getattr(out, level)
-		text = '[' + level + '] ' + text
+		comment = (padding + ' -- [' + level + '] ' + text) if text != '' else ''
 		if first:
 			if first and level == 'info':
 				f = out.good
-			f(prefix + alg_name + padding + ' -- ' + text)
+			f(prefix + alg_name + comment)
 			first = False
-		else:
+		else:  # pylint: disable=else-if-used
 			if out.verbose:
-				f(prefix + alg_name + padding + ' -- ' + text)
-			else:
-				f(' ' * len(prefix + alg_name) + padding + ' `- ' + text)
+				f(prefix + alg_name + comment)
+			elif text != '':
+				comment = (padding + ' `- [' + level + '] ' + text)
+				f(' ' * len(prefix + alg_name) + comment)
 
 
-def output_compatibility(kex, pkm, for_server=True):
-	# type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool) -> None
-	alg_pairs = get_alg_pairs(kex, pkm)
-	ssh_timeframe = get_ssh_timeframe(alg_pairs, for_server)
-	vp = 1 if for_server else 2
+def output_compatibility(algs, for_server=True):
+	# type: (SSH.Algorithms, bool) -> None
+	ssh_timeframe = algs.get_ssh_timeframe(for_server)
 	comp_text = []
-	for sshd_name in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]:
-		if sshd_name not in ssh_timeframe:
+	for ssh_prod in [SSH.Product.OpenSSH, SSH.Product.DropbearSSH]:
+		if ssh_prod not in ssh_timeframe:
 			continue
-		v = ssh_timeframe[sshd_name]
-		if v[vp] is None:
-			comp_text.append('{0} {1}+'.format(sshd_name, v[0]))
-		elif v[0] == v[vp]:
-			comp_text.append('{0} {1}'.format(sshd_name, v[0]))
+		v_from = ssh_timeframe.get_from(ssh_prod, for_server)
+		v_till = ssh_timeframe.get_till(ssh_prod, for_server)
+		if v_from is None:
+			continue
+		if v_till is None:
+			comp_text.append('{0} {1}+'.format(ssh_prod, v_from))
+		elif v_from == v_till:
+			comp_text.append('{0} {1}'.format(ssh_prod, v_from))
 		else:
-			if v[vp] < v[0]:
+			software = SSH.Software(None, ssh_prod, v_from, None, None)
+			if software.compare_version(v_till) > 0:
 				tfmt = '{0} {1}+ (some functionality from {2})'
 			else:
 				tfmt = '{0} {1}-{2}'
-			comp_text.append(tfmt.format(sshd_name, v[0], v[vp]))
+			comp_text.append(tfmt.format(ssh_prod, v_from, v_till))
 	if len(comp_text) > 0:
 		out.good('(gen) compatibility: ' + ', '.join(comp_text))
 
 
 def output_security_sub(sub, software, padlen):
-	# type: (str, SSH.Software, int) -> None
+	# type: (str, Optional[SSH.Software], int) -> None
 	secdb = SSH.Security.CVE if sub == 'cve' else SSH.Security.TXT
 	if software is None or software.product not in secdb:
 		return
@@ -1738,8 +2566,9 @@ def output_security_sub(sub, software, padlen):
 		if not software.between_versions(vfrom, vtill):
 			continue
 		target, name = line[2:4]  # type: int, str
-		is_server, is_client = target & 1 == 1, target & 2 == 2
-		is_local = target & 4 == 4
+		is_server = target & 1 == 1
+		# is_client = target & 2 == 2
+		# is_local = target & 4 == 4
 		if not is_server:
 			continue
 		p = '' if out.batch else ' ' * (padlen - len(name))
@@ -1752,9 +2581,9 @@ def output_security_sub(sub, software, padlen):
 
 
 def output_security(banner, padlen):
-	# type: (SSH.Banner, int) -> None
+	# type: (Optional[SSH.Banner], int) -> None
 	with OutputBuffer() as obuf:
-		if banner:
+		if banner is not None:
 			software = SSH.Software.parse(banner)
 			output_security_sub('cve', software, padlen)
 			output_security_sub('txt', software, padlen)
@@ -1764,14 +2593,14 @@ def output_security(banner, padlen):
 		out.sep()
 
 
-def output_fingerprint(kex, pkm, sha256=True, padlen=0):
-	# type: (Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage], bool, int) -> None
+def output_fingerprint(algs, sha256=True, padlen=0):
+	# type: (SSH.Algorithms, bool, int) -> None
 	with OutputBuffer() as obuf:
 		fps = []
-		if pkm is not None:
+		if algs.ssh1kex is not None:
 			name = 'ssh-rsa1'
-			fp = SSH.Fingerprint(pkm.host_key_fingerprint_data)
-			bits = pkm.host_key_bits
+			fp = SSH.Fingerprint(algs.ssh1kex.host_key_fingerprint_data)
+			bits = algs.ssh1kex.host_key_bits
 			fps.append((name, fp, bits))
 		for fpp in fps:
 			name, fp, bits = fpp
@@ -1784,33 +2613,40 @@ def output_fingerprint(kex, pkm, sha256=True, padlen=0):
 		out.sep()
 
 
-def output_recommendations(software, kex, pkm, padlen=0):
-	# type: (SSH.Software, SSH2.Kex, SSH1.PublicKeyMessage, int) -> None
+def output_recommendations(algs, software, padlen=0):
+	# type: (SSH.Algorithms, Optional[SSH.Software], int) -> None
 	for_server = True
 	with OutputBuffer() as obuf:
-		software, alg_rec = get_alg_recommendations(software, kex, pkm, for_server)
+		software, alg_rec = algs.get_recommendations(software, for_server)
 		for sshv in range(2, 0, -1):
 			if sshv not in alg_rec:
 				continue
 			for alg_type in ['kex', 'key', 'enc', 'mac']:
 				if alg_type not in alg_rec[sshv]:
 					continue
-				for action in ['del', 'add']:
+				for action in ['del', 'add', 'chg']:
 					if action not in alg_rec[sshv][alg_type]:
 						continue
 					for name in alg_rec[sshv][alg_type][action]:
 						p = '' if out.batch else ' ' * (padlen - len(name))
+						chg_additional_info = ''
 						if action == 'del':
 							an, sg, fn = 'remove', '-', out.warn
 							if alg_rec[sshv][alg_type][action][name] >= 10:
 								fn = out.fail
-						else:
+						elif action == 'add':
 							an, sg, fn = 'append', '+', out.good
+						elif action == 'chg':
+							an, sg, fn = 'change', '!', out.fail
+							chg_additional_info = ' (increase modulus size to 2048 bits or larger)'
 						b = '(SSH{0})'.format(sshv) if sshv == 1 else ''
-						fm = '(rec) {0}{1}{2}-- {3} algorithm to {4} {5}'
-						fn(fm.format(sg, name, p, alg_type, an, b))
+						fm = '(rec) {0}{1}{2}-- {3} algorithm to {4}{5} {6}'
+						fn(fm.format(sg, name, p, alg_type, an, chg_additional_info, b))
 	if len(obuf) > 0:
-		title = '(for {0})'.format(software.display(False)) if software else ''
+		if software is not None:
+			title = '(for {0})'.format(software.display(False))
+		else:
+			title = ''
 		out.head('# algorithm recommendations {0}'.format(title))
 		obuf.flush()
 		out.sep()
@@ -1818,7 +2654,8 @@ def output_recommendations(software, kex, pkm, padlen=0):
 
 def output(banner, header, kex=None, pkm=None):
 	# type: (Optional[SSH.Banner], List[text_type], Optional[SSH2.Kex], Optional[SSH1.PublicKeyMessage]) -> None
-	sshv = 1 if pkm else 2
+	sshv = 1 if pkm is not None else 2
+	algs = SSH.Algorithms(pkm, kex)
 	with OutputBuffer() as obuf:
 		if len(header) > 0:
 			out.info('(gen) header: ' + '\n'.join(header))
@@ -1834,7 +2671,7 @@ def output(banner, header, kex=None, pkm=None):
 				out.good('(gen) software: {0}'.format(software))
 		else:
 			software = None
-		output_compatibility(kex, pkm)
+		output_compatibility(algs)
 		if kex is not None:
 			compressions = [x for x in kex.server.compression if x != 'none']
 			if len(compressions) > 0:
@@ -1846,18 +2683,7 @@ def output(banner, header, kex=None, pkm=None):
 		out.head('# general')
 		obuf.flush()
 		out.sep()
-	ml, maxlen = lambda l: max(len(i) for i in l), 0
-	if pkm is not None:
-		maxlen = max(ml(pkm.supported_ciphers),
-		             ml(pkm.supported_authentications),
-		             maxlen)
-	if kex is not None:
-		maxlen = max(ml(kex.kex_algorithms),
-		             ml(kex.key_algorithms),
-		             ml(kex.server.encryption),
-		             ml(kex.server.mac),
-		             maxlen)
-	maxlen += 1
+	maxlen = algs.maxlen + 1
 	output_security(banner, maxlen)
 	if pkm is not None:
 		adb = SSH1.KexDB.ALGORITHMS
@@ -1870,17 +2696,17 @@ def output(banner, header, kex=None, pkm=None):
 		title, atype = 'SSH1 authentication types', 'aut'
 		output_algorithms(title, adb, atype, auths, maxlen)
 	if kex is not None:
-		adb = KexDB.ALGORITHMS
+		adb = SSH2.KexDB.ALGORITHMS
 		title, atype = 'key exchange algorithms', 'kex'
-		output_algorithms(title, adb, atype, kex.kex_algorithms, maxlen)
+		output_algorithms(title, adb, atype, kex.kex_algorithms, maxlen, kex.dh_modulus_sizes())
 		title, atype = 'host-key algorithms', 'key'
-		output_algorithms(title, adb, atype, kex.key_algorithms, maxlen)
+		output_algorithms(title, adb, atype, kex.key_algorithms, maxlen, kex.rsa_key_sizes())
 		title, atype = 'encryption algorithms (ciphers)', 'enc'
 		output_algorithms(title, adb, atype, kex.server.encryption, maxlen)
 		title, atype = 'message authentication code algorithms', 'mac'
 		output_algorithms(title, adb, atype, kex.server.mac, maxlen)
-	output_recommendations(software, kex, pkm, maxlen)
-	output_fingerprint(kex, pkm, True, maxlen)
+	output_recommendations(algs, software, maxlen)
+	output_fingerprint(algs, True, maxlen)
 
 
 class Utils(object):
@@ -1913,28 +2739,58 @@ class Utils(object):
 		if isinstance(v, str):
 			return v
 		elif isinstance(v, text_type):
-			return v.encode(enc)
+			return v.encode(enc)  # PY2 only
 		elif isinstance(v, binary_type):
-			return v.decode(enc)
+			return v.decode(enc)  # PY3 only
 		raise cls._type_err(v, 'native text')
 	
+	@classmethod
+	def _is_ascii(cls, v, char_filter=lambda x: x <= 127):
+		# type: (Union[text_type, str], Callable[[int], bool]) -> bool
+		r = False
+		if isinstance(v, (text_type, str)):
+			for c in v:
+				i = cls.ctoi(c)
+				if not char_filter(i):
+					return r
+			r = True
+		return r
+	
+	@classmethod
+	def _to_ascii(cls, v, char_filter=lambda x: x <= 127, errors='replace'):
+		# type: (Union[text_type, str], Callable[[int], bool], str) -> str
+		if isinstance(v, (text_type, str)):
+			r = bytearray()
+			for c in v:
+				i = cls.ctoi(c)
+				if char_filter(i):
+					r.append(i)
+				else:
+					if errors == 'ignore':
+						continue
+					r.append(63)
+			return cls.to_ntext(r.decode('ascii'))
+		raise cls._type_err(v, 'ascii')
+	
 	@classmethod
 	def is_ascii(cls, v):
 		# type: (Union[text_type, str]) -> bool
-		try:
-			if isinstance(v, (text_type, str)):
-				v.encode('ascii')
-				return True
-		except UnicodeEncodeError:
-			pass
-		return False
+		return cls._is_ascii(v)
 	
 	@classmethod
 	def to_ascii(cls, v, errors='replace'):
 		# type: (Union[text_type, str], str) -> str
-		if isinstance(v, (text_type, str)):
-			return cls.to_ntext(v.encode('ascii', errors))
-		raise cls._type_err(v, 'ascii')
+		return cls._to_ascii(v, errors=errors)
+	
+	@classmethod
+	def is_print_ascii(cls, v):
+		# type: (Union[text_type, str]) -> bool
+		return cls._is_ascii(v, lambda x: x >= 32 and x <= 126)
+	
+	@classmethod
+	def to_print_ascii(cls, v, errors='replace'):
+		# type: (Union[text_type, str], str) -> str
+		return cls._to_ascii(v, lambda x: x >= 32 and x <= 126, errors)
 	
 	@classmethod
 	def unique_seq(cls, seq):
@@ -1950,7 +2806,15 @@ class Utils(object):
 			return tuple(x for x in seq if x not in seen and not _seen_add(x))
 		else:
 			return [x for x in seq if x not in seen and not _seen_add(x)]
-		
+	
+	@classmethod
+	def ctoi(cls, c):
+		# type: (Union[text_type, str, int]) -> int
+		if isinstance(c, (text_type, str)):
+			return ord(c[0])
+		else:
+			return c
+	
 	@staticmethod
 	def parse_int(v):
 		# type: (Any) -> int
@@ -1959,26 +2823,40 @@ class Utils(object):
 		except:  # pylint: disable=bare-except
 			return 0
 
+	@staticmethod
+	def parse_float(v):
+		# type: (Any) -> float
+		try:
+			return float(v)
+		except:  # pylint: disable=bare-except
+			return -1.0
+
 
 def audit(aconf, sshv=None):
 	# type: (AuditConf, Optional[int]) -> None
 	out.batch = aconf.batch
-	out.colors = aconf.colors
 	out.verbose = aconf.verbose
-	out.minlevel = aconf.minlevel
-	s = SSH.Socket(aconf.host, aconf.port)
-	s.connect(aconf.ipvo)
+	out.level = aconf.level
+	out.use_colors = aconf.colors
+	s = SSH.Socket(aconf.host, aconf.port, aconf.ipvo, aconf.timeout)
+	s.connect()
 	if sshv is None:
 		sshv = 2 if aconf.ssh2 else 1
 	err = None
-	banner, header = s.get_banner(sshv)
+	banner, header, err = s.get_banner(sshv)
 	if banner is None:
-		err = '[exception] did not receive banner.'
+		if err is None:
+			err = '[exception] did not receive banner.'
+		else:
+			err = '[exception] did not receive banner: {0}'.format(err)
 	if err is None:
 		packet_type, payload = s.read_packet(sshv)
 		if packet_type < 0:
 			try:
-				payload_txt = payload.decode('utf-8') if payload else u'empty'
+				if payload is not None and len(payload) > 0:
+					payload_txt = payload.decode('utf-8')
+				else:
+					payload_txt = u'empty'
 			except UnicodeDecodeError:
 				payload_txt = u'"{0}"'.format(repr(payload).lstrip('b')[1:-1])
 			if payload_txt == u'Protocol major versions differ.':
@@ -1996,7 +2874,7 @@ def audit(aconf, sshv=None):
 				fmt = '[exception] did not receive {0} ({1}), ' + \
 				      'instead received unknown message ({2})'
 				err = fmt.format(err_pair[0], err_pair[1], packet_type)
-	if err:
+	if err is not None:
 		output(banner, header)
 		out.fail(err)
 		sys.exit(1)
@@ -2005,6 +2883,8 @@ def audit(aconf, sshv=None):
 		output(banner, header, pkm=pkm)
 	elif sshv == 2:
 		kex = SSH2.Kex.parse(payload)
+		SSH2.RSAKeyTest.run(s, kex)
+		SSH2.GEXTest.run(s, kex)
 		output(banner, header, kex=kex)
 
 
diff --git a/test/conftest.py b/test/conftest.py
index 524c0fa..0bc4124 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -40,6 +40,41 @@ def output_spy():
 	return _OutputSpy()
 
 
+class _VirtualGlobalSocket(object):
+	def __init__(self, vsocket):
+		self.vsocket = vsocket
+		self.addrinfodata = {}
+	
+	# pylint: disable=unused-argument
+	def create_connection(self, address, timeout=0, source_address=None):
+		# pylint: disable=protected-access
+		return self.vsocket._connect(address, True)
+	
+	# pylint: disable=unused-argument
+	def socket(self,
+	           family=socket.AF_INET,
+	           socktype=socket.SOCK_STREAM,
+	           proto=0,
+	           fileno=None):
+		return self.vsocket
+	
+	def getaddrinfo(self, host, port, family=0, socktype=0, proto=0, flags=0):
+		key = '{0}#{1}'.format(host, port)
+		if key in self.addrinfodata:
+			data = self.addrinfodata[key]
+			if isinstance(data, Exception):
+				raise data
+			return data
+		if host == 'localhost':
+			r = []
+			if family in (0, socket.AF_INET):
+				r.append((socket.AF_INET, 1, 6, '', ('127.0.0.1', port)))
+			if family in (0, socket.AF_INET6):
+				r.append((socket.AF_INET6, 1, 6, '', ('::1', port)))
+			return r
+		return []
+
+
 class _VirtualSocket(object):
 	def __init__(self):
 		self.sock_address = ('127.0.0.1', 0)
@@ -49,6 +84,7 @@ class _VirtualSocket(object):
 		self.rdata = []
 		self.sdata = []
 		self.errors = {}
+		self.gsock = _VirtualGlobalSocket(self)
 	
 	def _check_err(self, method):
 		method_error = self.errors.get(method)
@@ -113,18 +149,8 @@ class _VirtualSocket(object):
 @pytest.fixture()
 def virtual_socket(monkeypatch):
 	vsocket = _VirtualSocket()
-	
-	# pylint: disable=unused-argument
-	def _socket(family=socket.AF_INET,
-	            socktype=socket.SOCK_STREAM,
-	            proto=0,
-	            fileno=None):
-		return vsocket
-	
-	def _cc(address, timeout=0, source_address=None):
-		# pylint: disable=protected-access
-		return vsocket._connect(address, True)
-	
-	monkeypatch.setattr(socket, 'create_connection', _cc)
-	monkeypatch.setattr(socket, 'socket', _socket)
+	gsock = vsocket.gsock
+	monkeypatch.setattr(socket, 'create_connection', gsock.create_connection)
+	monkeypatch.setattr(socket, 'socket', gsock.socket)
+	monkeypatch.setattr(socket, 'getaddrinfo', gsock.getaddrinfo)
 	return vsocket
diff --git a/test/coverage.sh b/test/coverage.sh
deleted file mode 100755
index 28f2010..0000000
--- a/test/coverage.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-_cdir=$(cd -- "$(dirname "$0")" && pwd)
-type py.test > /dev/null 2>&1
-if [ $? -ne 0 ]; then
-	echo "err: py.test (Python testing framework) not found."
-	exit 1
-fi
-cd -- "${_cdir}/.."
-mkdir -p html
-py.test -v --cov-report=html:html/coverage --cov=ssh-audit test
diff --git a/test/mypy-py2.sh b/test/mypy-py2.sh
deleted file mode 100755
index f8e9244..0000000
--- a/test/mypy-py2.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-_cdir=$(cd -- "$(dirname "$0")" && pwd)
-type mypy > /dev/null 2>&1
-if [ $? -ne 0 ]; then
-	echo "err: mypy (Optional Static Typing for Python) not found."
-	exit 1
-fi
-_htmldir="${_cdir}/../html/mypy-py2"
-mkdir -p "${_htmldir}"
-mypy --python-version 2.7 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py"
diff --git a/test/mypy-py3.sh b/test/mypy-py3.sh
deleted file mode 100755
index 0d2dfe5..0000000
--- a/test/mypy-py3.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/bin/sh
-_cdir=$(cd -- "$(dirname "$0")" && pwd)
-type mypy > /dev/null 2>&1
-if [ $? -ne 0 ]; then
-	echo "err: mypy (Optional Static Typing for Python) not found."
-	exit 1
-fi
-_htmldir="${_cdir}/../html/mypy-py3"
-mkdir -p "${_htmldir}"
-mypy --python-version 3.5 --config-file "${_cdir}/mypy.ini" --html-report "${_htmldir}" "${_cdir}/../ssh-audit.py"
diff --git a/test/mypy.ini b/test/mypy.ini
deleted file mode 100644
index 9c0a3e0..0000000
--- a/test/mypy.ini
+++ /dev/null
@@ -1,9 +0,0 @@
-[mypy]
-silent_imports = True
-disallow_untyped_calls = True
-disallow_untyped_defs = True
-check_untyped_defs = True
-disallow-subclassing-any = True
-warn-incomplete-stub = True
-warn-redundant-casts = True
-
diff --git a/test/prospector.sh b/test/prospector.sh
deleted file mode 100755
index 4398ec7..0000000
--- a/test/prospector.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/sh
-_cdir=$(cd -- "$(dirname "$0")" && pwd)
-type prospector > /dev/null 2>&1
-if [ $? -ne 0 ]; then
-	echo "err: prospector (Python Static Analysis) not found."
-	exit 1
-fi
-if [ X"$1" == X"" ]; then
-	_file="${_cdir}/../ssh-audit.py"
-else
-	_file="$1"
-fi
-prospector -E --profile-path "${_cdir}" -P prospector "${_file}"
diff --git a/test/prospector.yml b/test/prospector.yml
deleted file mode 100644
index 474af15..0000000
--- a/test/prospector.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-strictness: veryhigh
-doc-warnings: false
-
-pylint:
-    disable:
-        - multiple-imports
-        - invalid-name
-        - trailing-whitespace
-
-    options:
-        max-args: 8 # default: 5
-        max-locals: 20 # default: 15
-        max-returns: 6
-        max-branches: 15 # default: 12
-        max-statements: 60 # default: 50
-        max-parents: 7
-        max-attributes: 8 # default: 7
-        min-public-methods: 1 # default: 2
-        max-public-methods: 20
-        max-bool-expr: 5
-        max-nested-blocks: 6 # default: 5
-        max-line-length: 80 # default: 100
-        ignore-long-lines: ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?)$
-        max-module-lines: 2500 # default: 10000
-
-pep8:
-    disable:
-        - W191 # indentation contains tabs
-        - W293 # blank line contains whitespace
-        - E101 # indentation contains mixed spaces and tabs
-        - E401 # multiple imports on one line
-        - E501 # line too long
-        - E221 # multiple spaces before operator
-
-pyflakes:
-    disable:
-        - F401 # module imported but unused
-        - F821 # undefined name
-
-mccabe:
-    options:
-        max-complexity: 15
diff --git a/test/stubs/colorama.pyi b/test/stubs/colorama.pyi
new file mode 100644
index 0000000..81d6ef0
--- /dev/null
+++ b/test/stubs/colorama.pyi
@@ -0,0 +1,6 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+from typing import Optional
+
+def init(autoreset: bool = False, convert: Optional[bool] = None, strip: Optional[bool] = None, wrap: bool = True) -> None: ...
+
diff --git a/test/test_auditconf.py b/test/test_auditconf.py
index 3472c42..a901299 100644
--- a/test/test_auditconf.py
+++ b/test/test_auditconf.py
@@ -10,8 +10,8 @@ class TestAuditConf(object):
 		self.AuditConf = ssh_audit.AuditConf
 		self.usage = ssh_audit.usage
 	
-	@classmethod
-	def _test_conf(cls, conf, **kwargs):
+	@staticmethod
+	def _test_conf(conf, **kwargs):
 		options = {
 			'host': None,
 			'port': 22,
@@ -20,7 +20,7 @@ class TestAuditConf(object):
 			'batch': False,
 			'colors': True,
 			'verbose': False,
-			'minlevel': 'info',
+			'level': 'info',
 			'ipv4': True,
 			'ipv6': True,
 			'ipvo': ()
@@ -34,7 +34,7 @@ class TestAuditConf(object):
 		assert conf.batch is options['batch']
 		assert conf.colors is options['colors']
 		assert conf.verbose is options['verbose']
-		assert conf.minlevel == options['minlevel']
+		assert conf.level == options['level']
 		assert conf.ipv4 == options['ipv4']
 		assert conf.ipv6 == options['ipv6']
 		assert conf.ipvo == options['ipvo']
@@ -115,14 +115,14 @@ class TestAuditConf(object):
 		conf.ipvo = (4, 4, 4, 6, 6)
 		assert conf.ipvo == (4, 6)
 	
-	def test_audit_conf_minlevel(self):
+	def test_audit_conf_level(self):
 		conf = self.AuditConf()
 		for level in ['info', 'warn', 'fail']:
-			conf.minlevel = level
-			assert conf.minlevel == level
+			conf.level = level
+			assert conf.level == level
 		for level in ['head', 'good', 'unknown', None]:
 			with pytest.raises(ValueError) as excinfo:
-				conf.minlevel = level
+				conf.level = level
 			excinfo.match(r'.*invalid level.*')
 	
 	def test_audit_conf_cmdline(self):
@@ -148,6 +148,14 @@ class TestAuditConf(object):
 		self._test_conf(conf, host='localhost', port=2222)
 		conf = c('-p 2222 localhost')
 		self._test_conf(conf, host='localhost', port=2222)
+		conf = c('2001:4860:4860::8888')
+		self._test_conf(conf, host='2001:4860:4860::8888')
+		conf = c('[2001:4860:4860::8888]:22')
+		self._test_conf(conf, host='2001:4860:4860::8888')
+		conf = c('[2001:4860:4860::8888]:2222')
+		self._test_conf(conf, host='2001:4860:4860::8888', port=2222)
+		conf = c('-p 2222 2001:4860:4860::8888')
+		self._test_conf(conf, host='2001:4860:4860::8888', port=2222)
 		with pytest.raises(SystemExit):
 			conf = c('localhost:')
 		with pytest.raises(SystemExit):
@@ -183,10 +191,10 @@ class TestAuditConf(object):
 		conf = c('-v localhost')
 		self._test_conf(conf, host='localhost', verbose=True)
 		conf = c('-l info localhost')
-		self._test_conf(conf, host='localhost', minlevel='info')
+		self._test_conf(conf, host='localhost', level='info')
 		conf = c('-l warn localhost')
-		self._test_conf(conf, host='localhost', minlevel='warn')
+		self._test_conf(conf, host='localhost', level='warn')
 		conf = c('-l fail localhost')
-		self._test_conf(conf, host='localhost', minlevel='fail')
+		self._test_conf(conf, host='localhost', level='fail')
 		with pytest.raises(SystemExit):
 			conf = c('-l something localhost')
diff --git a/test/test_errors.py b/test/test_errors.py
index ad35a54..abf720e 100644
--- a/test/test_errors.py
+++ b/test/test_errors.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 import socket
+import errno
 import pytest
 
 
@@ -17,46 +18,99 @@ class TestErrors(object):
 		conf.batch = True
 		return conf
 	
+	def _audit(self, spy, conf=None, sysexit=True):
+		if conf is None:
+			conf = self._conf()
+		spy.begin()
+		if sysexit:
+			with pytest.raises(SystemExit):
+				self.audit(conf)
+		else:
+			self.audit(conf)
+		lines = spy.flush()
+		return lines
+	
+	def test_connection_unresolved(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.gsock.addrinfodata['localhost#22'] = []
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'has no DNS records' in lines[-1]
+	
 	def test_connection_refused(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
-		vsocket.errors['connect'] = socket.error(61, 'Connection refused')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		vsocket.errors['connect'] = socket.error(errno.ECONNREFUSED, 'Connection refused')
+		lines = self._audit(output_spy)
 		assert len(lines) == 1
 		assert 'Connection refused' in lines[-1]
 	
-	def test_connection_closed_before_banner(self, output_spy, virtual_socket):
+	def test_connection_timeout(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
-		vsocket.rdata.append(socket.error(54, 'Connection reset by peer'))
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		vsocket.errors['connect'] = socket.timeout('timed out')
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'timed out' in lines[-1]
+	
+	def test_recv_empty(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		lines = self._audit(output_spy)
 		assert len(lines) == 1
 		assert 'did not receive banner' in lines[-1]
 	
+	def test_recv_timeout(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.rdata.append(socket.timeout('timed out'))
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'did not receive banner' in lines[-1]
+		assert 'timed out' in lines[-1]
+	
+	def test_recv_retry_till_timeout(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.error(errno.EWOULDBLOCK, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.timeout('timed out'))
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'did not receive banner' in lines[-1]
+		assert 'timed out' in lines[-1]
+	
+	def test_recv_retry_till_reset(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.error(errno.EWOULDBLOCK, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.error(errno.EAGAIN, 'Resource temporarily unavailable'))
+		vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer'))
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'did not receive banner' in lines[-1]
+		assert 'reset by peer' in lines[-1]
+	
+	def test_connection_closed_before_banner(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer'))
+		lines = self._audit(output_spy)
+		assert len(lines) == 1
+		assert 'did not receive banner' in lines[-1]
+		assert 'reset by peer' in lines[-1]
+	
 	def test_connection_closed_after_header(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'header line 1\n')
+		vsocket.rdata.append(b'\n')
 		vsocket.rdata.append(b'header line 2\n')
-		vsocket.rdata.append(socket.error(54, 'Connection reset by peer'))
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		vsocket.rdata.append(socket.error(errno.ECONNRESET, 'Connection reset by peer'))
+		lines = self._audit(output_spy)
 		assert len(lines) == 3
 		assert 'did not receive banner' in lines[-1]
+		assert 'reset by peer' in lines[-1]
 	
 	def test_connection_closed_after_banner(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n')
 		vsocket.rdata.append(socket.error(54, 'Connection reset by peer'))
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		lines = self._audit(output_spy)
 		assert len(lines) == 2
 		assert 'error reading packet' in lines[-1]
 		assert 'reset by peer' in lines[-1]
@@ -64,10 +118,7 @@ class TestErrors(object):
 	def test_empty_data_after_banner(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		lines = self._audit(output_spy)
 		assert len(lines) == 2
 		assert 'error reading packet' in lines[-1]
 		assert 'empty' in lines[-1]
@@ -76,10 +127,7 @@ class TestErrors(object):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n')
 		vsocket.rdata.append(b'xxx\n')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		lines = self._audit(output_spy)
 		assert len(lines) == 2
 		assert 'error reading packet' in lines[-1]
 		assert 'xxx' in lines[-1]
@@ -87,10 +135,7 @@ class TestErrors(object):
 	def test_non_ascii_banner(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\xc3\xbc\r\n')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		lines = self._audit(output_spy)
 		assert len(lines) == 3
 		assert 'error reading packet' in lines[-1]
 		assert 'ASCII' in lines[-2]
@@ -100,10 +145,7 @@ class TestErrors(object):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-2.0-ssh-audit-test\r\n')
 		vsocket.rdata.append(b'\x81\xff\n')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			self.audit(self._conf())
-		lines = output_spy.flush()
+		lines = self._audit(output_spy)
 		assert len(lines) == 2
 		assert 'error reading packet' in lines[-1]
 		assert '\\x81\\xff' in lines[-1]
@@ -112,12 +154,9 @@ class TestErrors(object):
 		vsocket = virtual_socket
 		vsocket.rdata.append(b'SSH-1.3-ssh-audit-test\r\n')
 		vsocket.rdata.append(b'Protocol major versions differ.\n')
-		output_spy.begin()
-		with pytest.raises(SystemExit):
-			conf = self._conf()
-			conf.ssh1, conf.ssh2 = True, False
-			self.audit(conf)
-		lines = output_spy.flush()
+		conf = self._conf()
+		conf.ssh1, conf.ssh2 = True, False
+		lines = self._audit(output_spy, conf)
 		assert len(lines) == 3
 		assert 'error reading packet' in lines[-1]
 		assert 'major versions differ' in lines[-1]
diff --git a/test/test_output.py b/test/test_output.py
index 74b2c19..3ac6f06 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -41,13 +41,13 @@ class TestOutput(object):
 		out = self.Output()
 		# default: on
 		assert out.batch is False
-		assert out.colors is True
-		assert out.minlevel == 'info'
+		assert out.use_colors is True
+		assert out.level == 'info'
 	
 	def test_output_colors(self, output_spy):
 		out = self.Output()
 		# test without colors
-		out.colors = False
+		out.use_colors = False
 		output_spy.begin()
 		out.info('info color')
 		assert output_spy.flush() == [u'info color']
@@ -66,7 +66,7 @@ class TestOutput(object):
 		if not out.colors_supported:
 			return
 		# test with colors
-		out.colors = True
+		out.use_colors = True
 		output_spy.begin()
 		out.info('info color')
 		assert output_spy.flush() == [u'info color']
@@ -93,29 +93,29 @@ class TestOutput(object):
 	
 	def test_output_levels(self):
 		out = self.Output()
-		assert out.getlevel('info') == 0
-		assert out.getlevel('good') == 0
-		assert out.getlevel('warn') == 1
-		assert out.getlevel('fail') == 2
-		assert out.getlevel('unknown') > 2
+		assert out.get_level('info') == 0
+		assert out.get_level('good') == 0
+		assert out.get_level('warn') == 1
+		assert out.get_level('fail') == 2
+		assert out.get_level('unknown') > 2
 	
-	def test_output_minlevel_property(self):
+	def test_output_level_property(self):
 		out = self.Output()
-		out.minlevel = 'info'
-		assert out.minlevel == 'info'
-		out.minlevel = 'good'
-		assert out.minlevel == 'info'
-		out.minlevel = 'warn'
-		assert out.minlevel == 'warn'
-		out.minlevel = 'fail'
-		assert out.minlevel == 'fail'
-		out.minlevel = 'invalid level'
-		assert out.minlevel == 'unknown'
+		out.level = 'info'
+		assert out.level == 'info'
+		out.level = 'good'
+		assert out.level == 'info'
+		out.level = 'warn'
+		assert out.level == 'warn'
+		out.level = 'fail'
+		assert out.level == 'fail'
+		out.level = 'invalid level'
+		assert out.level == 'unknown'
 	
-	def test_output_minlevel(self, output_spy):
+	def test_output_level(self, output_spy):
 		out = self.Output()
 		# visible: all
-		out.minlevel = 'info'
+		out.level = 'info'
 		output_spy.begin()
 		out.info('info color')
 		out.head('head color')
@@ -124,7 +124,7 @@ class TestOutput(object):
 		out.fail('fail color')
 		assert len(output_spy.flush()) == 5
 		# visible: head, warn, fail
-		out.minlevel = 'warn'
+		out.level = 'warn'
 		output_spy.begin()
 		out.info('info color')
 		out.head('head color')
@@ -133,7 +133,7 @@ class TestOutput(object):
 		out.fail('fail color')
 		assert len(output_spy.flush()) == 3
 		# visible: head, fail
-		out.minlevel = 'fail'
+		out.level = 'fail'
 		output_spy.begin()
 		out.info('info color')
 		out.head('head color')
@@ -142,7 +142,7 @@ class TestOutput(object):
 		out.fail('fail color')
 		assert len(output_spy.flush()) == 2
 		# visible: head
-		out.minlevel = 'invalid level'
+		out.level = 'invalid level'
 		output_spy.begin()
 		out.info('info color')
 		out.head('head color')
@@ -155,7 +155,7 @@ class TestOutput(object):
 		out = self.Output()
 		# visible: all
 		output_spy.begin()
-		out.minlevel = 'info'
+		out.level = 'info'
 		out.batch = False
 		out.info('info color')
 		out.head('head color')
@@ -165,7 +165,7 @@ class TestOutput(object):
 		assert len(output_spy.flush()) == 5
 		# visible: all except head
 		output_spy.begin()
-		out.minlevel = 'info'
+		out.level = 'info'
 		out.batch = True
 		out.info('info color')
 		out.head('head color')
diff --git a/test/test_resolve.py b/test/test_resolve.py
new file mode 100644
index 0000000..8fcddf6
--- /dev/null
+++ b/test/test_resolve.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import socket
+import pytest
+
+
+# pylint: disable=attribute-defined-outside-init,protected-access
+class TestResolve(object):
+	@pytest.fixture(autouse=True)
+	def init(self, ssh_audit):
+		self.AuditConf = ssh_audit.AuditConf
+		self.audit = ssh_audit.audit
+		self.ssh = ssh_audit.SSH
+	
+	def _conf(self):
+		conf = self.AuditConf('localhost', 22)
+		conf.colors = False
+		conf.batch = True
+		return conf
+	
+	def test_resolve_error(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.gsock.addrinfodata['localhost#22'] = socket.gaierror(8, 'hostname nor servname provided, or not known')
+		s = self.ssh.Socket('localhost', 22)
+		conf = self._conf()
+		output_spy.begin()
+		with pytest.raises(SystemExit):
+			r = list(s._resolve(conf.ipvo))
+		lines = output_spy.flush()
+		assert len(lines) == 1
+		assert 'hostname nor servname provided' in lines[-1]
+	
+	def test_resolve_hostname_without_records(self, output_spy, virtual_socket):
+		vsocket = virtual_socket
+		vsocket.gsock.addrinfodata['localhost#22'] = []
+		s = self.ssh.Socket('localhost', 22)
+		conf = self._conf()
+		output_spy.begin()
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 0
+	
+	def test_resolve_ipv4(self, virtual_socket):
+		vsocket = virtual_socket
+		conf = self._conf()
+		conf.ipv4 = True
+		s = self.ssh.Socket('localhost', 22)
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 1
+		assert r[0] == (socket.AF_INET, ('127.0.0.1', 22))
+	
+	def test_resolve_ipv6(self, virtual_socket):
+		vsocket = virtual_socket
+		s = self.ssh.Socket('localhost', 22)
+		conf = self._conf()
+		conf.ipv6 = True
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 1
+		assert r[0] == (socket.AF_INET6, ('::1', 22))
+	
+	def test_resolve_ipv46_both(self, virtual_socket):
+		vsocket = virtual_socket
+		s = self.ssh.Socket('localhost', 22)
+		conf = self._conf()
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 2
+		assert r[0] == (socket.AF_INET, ('127.0.0.1', 22))
+		assert r[1] == (socket.AF_INET6, ('::1', 22))
+	
+	def test_resolve_ipv46_order(self, virtual_socket):
+		vsocket = virtual_socket
+		s = self.ssh.Socket('localhost', 22)
+		conf = self._conf()
+		conf.ipv4 = True
+		conf.ipv6 = True
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 2
+		assert r[0] == (socket.AF_INET, ('127.0.0.1', 22))
+		assert r[1] == (socket.AF_INET6, ('::1', 22))
+		conf = self._conf()
+		conf.ipv6 = True
+		conf.ipv4 = True
+		r = list(s._resolve(conf.ipvo))
+		assert len(r) == 2
+		assert r[0] == (socket.AF_INET6, ('::1', 22))
+		assert r[1] == (socket.AF_INET, ('127.0.0.1', 22))
diff --git a/test/test_socket.py b/test/test_socket.py
new file mode 100644
index 0000000..d5c27fc
--- /dev/null
+++ b/test/test_socket.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import socket
+import pytest
+
+
+# pylint: disable=attribute-defined-outside-init
+class TestSocket(object):
+	@pytest.fixture(autouse=True)
+	def init(self, ssh_audit):
+		self.ssh = ssh_audit.SSH
+	
+	def test_invalid_host(self, virtual_socket):
+		with pytest.raises(ValueError):
+			s = self.ssh.Socket(None, 22)
+	
+	def test_invalid_port(self, virtual_socket):
+		with pytest.raises(ValueError):
+			s = self.ssh.Socket('localhost', 'abc')
+		with pytest.raises(ValueError):
+			s = self.ssh.Socket('localhost', -1)
+		with pytest.raises(ValueError):
+			s = self.ssh.Socket('localhost', 0)
+		with pytest.raises(ValueError):
+			s = self.ssh.Socket('localhost', 65536)
+	
+	def test_not_connected_socket(self, virtual_socket):
+		sock = self.ssh.Socket('localhost', 22)
+		banner, header, err = sock.get_banner()
+		assert banner is None
+		assert len(header) == 0
+		assert err == 'not connected'
+		s, e = sock.recv()
+		assert s == -1
+		assert e == 'not connected'
+		s, e = sock.send('nothing')
+		assert s == -1
+		assert e == 'not connected'
+		s, e = sock.send_packet()
+		assert s == -1
+		assert e == 'not connected'
diff --git a/test/test_software.py b/test/test_software.py
index 141ffec..4785041 100644
--- a/test/test_software.py
+++ b/test/test_software.py
@@ -168,17 +168,17 @@ class TestSoftware(object):
 		assert s.display(True) == str(s)
 		assert s.display(False) == str(s)
 		assert repr(s) == '<Software(product=libssh, version=0.2)>'
-		s = ps('SSH-2.0-libssh-0.7.3')
+		s = ps('SSH-2.0-libssh-0.7.4')
 		assert s.vendor is None
 		assert s.product == 'libssh'
-		assert s.version == '0.7.3'
+		assert s.version == '0.7.4'
 		assert s.patch is None
 		assert s.os is None
-		assert str(s) == 'libssh 0.7.3'
+		assert str(s) == 'libssh 0.7.4'
 		assert str(s) == s.display()
 		assert s.display(True) == str(s)
 		assert s.display(False) == str(s)
-		assert repr(s) == '<Software(product=libssh, version=0.7.3)>'
+		assert repr(s) == '<Software(product=libssh, version=0.7.4)>'
 	
 	def test_romsshell_software(self):
 		ps = lambda x: self.ssh.Software.parse(self.ssh.Banner.parse(x))  # noqa
diff --git a/test/test_ssh1.py b/test/test_ssh1.py
index 0029845..f18e4be 100644
--- a/test/test_ssh1.py
+++ b/test/test_ssh1.py
@@ -66,34 +66,51 @@ class TestSSH1(object):
 		assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96'
 		assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs'
 	
-	def test_pkm_read(self):
-		pkm = self.ssh1.PublicKeyMessage.parse(self._pkm_payload())
-		assert pkm is not None
-		assert pkm.cookie == b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff'
-		b, e, m = self._server_key()
+	def _assert_pkm_keys(self, pkm, skey, hkey):
+		b, e, m = skey
 		assert pkm.server_key_bits == b
 		assert pkm.server_key_public_exponent == e
 		assert pkm.server_key_public_modulus == m
-		b, e, m = self._host_key()
+		b, e, m = hkey
 		assert pkm.host_key_bits == b
 		assert pkm.host_key_public_exponent == e
 		assert pkm.host_key_public_modulus == m
-		fp = self.ssh.Fingerprint(pkm.host_key_fingerprint_data)
+	
+	def _assert_pkm_fields(self, pkm, skey, hkey):
+		assert pkm is not None
+		assert pkm.cookie == b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff'
+		self._assert_pkm_keys(pkm, skey, hkey)
 		assert pkm.protocol_flags == 2
 		assert pkm.supported_ciphers_mask == 72
 		assert pkm.supported_ciphers == ['3des', 'blowfish']
 		assert pkm.supported_authentications_mask == 36
 		assert pkm.supported_authentications == ['rsa', 'tis']
+		fp = self.ssh.Fingerprint(pkm.host_key_fingerprint_data)
 		assert fp.md5 == 'MD5:9d:26:f8:39:fc:20:9d:9b:ca:cc:4a:0f:e1:93:f5:96'
 		assert fp.sha256 == 'SHA256:vZdx3mhzbvVJmn08t/ruv8WDhJ9jfKYsCTuSzot+QIs'
 	
+	def test_pkm_init(self):
+		cookie = b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff'
+		pflags, cmask, amask = 2, 72, 36
+		skey, hkey = self._server_key(), self._host_key()
+		pkm = self.ssh1.PublicKeyMessage(cookie, skey, hkey, pflags, cmask, amask)
+		self._assert_pkm_fields(pkm, skey, hkey)
+		for skey2 in ([], [0], [0,1], [0,1,2,3]):
+			with pytest.raises(ValueError):
+				pkm = self.ssh1.PublicKeyMessage(cookie, skey2, hkey, pflags, cmask, amask)
+		for hkey2 in ([], [0], [0,1], [0,1,2,3]):
+			with pytest.raises(ValueError):
+				print(hkey2)
+				pkm = self.ssh1.PublicKeyMessage(cookie, skey, hkey2, pflags, cmask, amask)
+	
+	def test_pkm_read(self):
+		pkm = self.ssh1.PublicKeyMessage.parse(self._pkm_payload())
+		self._assert_pkm_fields(pkm, self._server_key(), self._host_key())
+	
 	def test_pkm_payload(self):
 		cookie = b'\x88\x99\xaa\xbb\xcc\xdd\xee\xff'
-		skey = self._server_key()
-		hkey = self._host_key()
-		pflags = 2
-		cmask = 72
-		amask = 36
+		skey, hkey = self._server_key(), self._host_key()
+		pflags, cmask, amask = 2, 72, 36
 		pkm1 = self.ssh1.PublicKeyMessage(cookie, skey, hkey, pflags, cmask, amask)
 		pkm2 = self.ssh1.PublicKeyMessage.parse(self._pkm_payload())
 		assert pkm1.payload == pkm2.payload
@@ -108,7 +125,7 @@ class TestSSH1(object):
 		output_spy.begin()
 		self.audit(self._conf())
 		lines = output_spy.flush()
-		assert len(lines) == 10
+		assert len(lines) == 13
 	
 	def test_ssh1_server_invalid_first_packet(self, output_spy, virtual_socket):
 		vsocket = virtual_socket
@@ -121,7 +138,7 @@ class TestSSH1(object):
 		with pytest.raises(SystemExit):
 			self.audit(self._conf())
 		lines = output_spy.flush()
-		assert len(lines) == 4
+		assert len(lines) == 7
 		assert 'unknown message' in lines[-1]
 
 	def test_ssh1_server_invalid_checksum(self, output_spy, virtual_socket):
diff --git a/test/test_ssh_algorithm.py b/test/test_ssh_algorithm.py
new file mode 100644
index 0000000..5e03529
--- /dev/null
+++ b/test/test_ssh_algorithm.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import pytest
+
+
+# pylint: disable=attribute-defined-outside-init
+class TestSSHAlgorithm(object):
+	@pytest.fixture(autouse=True)
+	def init(self, ssh_audit):
+		self.ssh = ssh_audit.SSH
+	
+	def _tf(self, v, s=None):
+		return self.ssh.Algorithm.Timeframe().update(v, s)
+	
+	def test_get_ssh_version(self):
+		def ver(v):
+			return self.ssh.Algorithm.get_ssh_version(v)
+		
+		assert ver('7.5') == ('OpenSSH', '7.5', False)
+		assert ver('7.5C') == ('OpenSSH', '7.5', True)
+		assert ver('d2016.74') == ('Dropbear SSH', '2016.74', False)
+		assert ver('l10.7.4') == ('libssh', '0.7.4', False)
+		assert ver('')[1] == ''
+	
+	def test_get_since_text(self):
+		def gst(v):
+			return self.ssh.Algorithm.get_since_text(v)
+		
+		assert gst(['7.5']) == 'available since OpenSSH 7.5'
+		assert gst(['7.5C']) == 'available since OpenSSH 7.5 (client only)'
+		assert gst(['7.5,']) == 'available since OpenSSH 7.5'
+		assert gst(['d2016.73']) == 'available since Dropbear SSH 2016.73'
+		assert gst(['7.5,d2016.73']) == 'available since OpenSSH 7.5, Dropbear SSH 2016.73'
+		assert gst(['l10.7.4']) is None
+		assert gst([]) is None
+	
+	def test_timeframe_creation(self):
+		# pylint: disable=line-too-long,too-many-statements
+		def cmp_tf(v, s, r):
+			assert str(self._tf(v, s)) == str(r)
+		
+		cmp_tf(['6.2'], None, {'OpenSSH': ['6.2', None, '6.2', None]})
+		cmp_tf(['6.2'], True, {'OpenSSH': ['6.2', None, None, None]})
+		cmp_tf(['6.2'], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C'], None, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C'], True, {})
+		cmp_tf(['6.2C'], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.1,6.2C'], None, {'OpenSSH': ['6.1', None, '6.2', None]})
+		cmp_tf(['6.1,6.2C'], True, {'OpenSSH': ['6.1', None, None, None]})
+		cmp_tf(['6.1,6.2C'], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C,6.1'], None, {'OpenSSH': ['6.1', None, '6.2', None]})
+		cmp_tf(['6.2C,6.1'], True, {'OpenSSH': ['6.1', None, None, None]})
+		cmp_tf(['6.2C,6.1'], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.3,6.2C'], None, {'OpenSSH': ['6.3', None, '6.2', None]})
+		cmp_tf(['6.3,6.2C'], True, {'OpenSSH': ['6.3', None, None, None]})
+		cmp_tf(['6.3,6.2C'], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C,6.3'], None, {'OpenSSH': ['6.3', None, '6.2', None]})
+		cmp_tf(['6.2C,6.3'], True, {'OpenSSH': ['6.3', None, None, None]})
+		cmp_tf(['6.2C,6.3'], False, {'OpenSSH': [None, None, '6.2', None]})
+		
+		cmp_tf(['6.2', '6.6'], None, {'OpenSSH': ['6.2', '6.6', '6.2', '6.6']})
+		cmp_tf(['6.2', '6.6'], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.2', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		cmp_tf(['6.2C', '6.6'], None, {'OpenSSH': [None, '6.6', '6.2', '6.6']})
+		cmp_tf(['6.2C', '6.6'], True, {'OpenSSH': [None, '6.6', None, None]})
+		cmp_tf(['6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		cmp_tf(['6.1,6.2C', '6.6'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '6.6']})
+		cmp_tf(['6.1,6.2C', '6.6'], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.1,6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		cmp_tf(['6.2C,6.1', '6.6'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '6.6']})
+		cmp_tf(['6.2C,6.1', '6.6'], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.2C,6.1', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		cmp_tf(['6.3,6.2C', '6.6'], None, {'OpenSSH': ['6.3', '6.6', '6.2', '6.6']})
+		cmp_tf(['6.3,6.2C', '6.6'], True, {'OpenSSH': ['6.3', '6.6', None, None]})
+		cmp_tf(['6.3,6.2C', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		cmp_tf(['6.2C,6.3', '6.6'], None, {'OpenSSH': ['6.3', '6.6', '6.2', '6.6']})
+		cmp_tf(['6.2C,6.3', '6.6'], True, {'OpenSSH': ['6.3', '6.6', None, None]})
+		cmp_tf(['6.2C,6.3', '6.6'], False, {'OpenSSH': [None, None, '6.2', '6.6']})
+		
+		cmp_tf(['6.2', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.2', None]})
+		cmp_tf(['6.2', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.2', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C', '6.6', None], None, {'OpenSSH': [None, '6.6', '6.2', None]})
+		cmp_tf(['6.2C', '6.6', None], True, {'OpenSSH': [None, '6.6', None, None]})
+		cmp_tf(['6.2C', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.1,6.2C', '6.6', None], None, {'OpenSSH': ['6.1', '6.6', '6.2', None]})
+		cmp_tf(['6.1,6.2C', '6.6', None], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.1,6.2C', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2C,6.1', '6.6', None], None, {'OpenSSH': ['6.1', '6.6', '6.2', None]})
+		cmp_tf(['6.2C,6.1', '6.6', None], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.2C,6.1', '6.6', None], False, {'OpenSSH': [None, None, '6.2', None]})
+		cmp_tf(['6.2,6.3C', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.3', None]})
+		cmp_tf(['6.2,6.3C', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.2,6.3C', '6.6', None], False, {'OpenSSH': [None, None, '6.3', None]})
+		cmp_tf(['6.3C,6.2', '6.6', None], None, {'OpenSSH': ['6.2', '6.6', '6.3', None]})
+		cmp_tf(['6.3C,6.2', '6.6', None], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.3C,6.2', '6.6', None], False, {'OpenSSH': [None, None, '6.3', None]})
+		
+		cmp_tf(['6.2', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.2', '7.1']})
+		cmp_tf(['6.2', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.2', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']})
+		cmp_tf(['6.1,6.2C', '6.6', '7.1'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '7.1']})
+		cmp_tf(['6.1,6.2C', '6.6', '7.1'], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.1,6.2C', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']})
+		cmp_tf(['6.2C,6.1', '6.6', '7.1'], None, {'OpenSSH': ['6.1', '6.6', '6.2', '7.1']})
+		cmp_tf(['6.2C,6.1', '6.6', '7.1'], True, {'OpenSSH': ['6.1', '6.6', None, None]})
+		cmp_tf(['6.2C,6.1', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.2', '7.1']})
+		cmp_tf(['6.2,6.3C', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.3', '7.1']})
+		cmp_tf(['6.2,6.3C', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.2,6.3C', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.3', '7.1']})
+		cmp_tf(['6.3C,6.2', '6.6', '7.1'], None, {'OpenSSH': ['6.2', '6.6', '6.3', '7.1']})
+		cmp_tf(['6.3C,6.2', '6.6', '7.1'], True, {'OpenSSH': ['6.2', '6.6', None, None]})
+		cmp_tf(['6.3C,6.2', '6.6', '7.1'], False, {'OpenSSH': [None, None, '6.3', '7.1']})
+		
+		tf1 = self._tf(['6.1,d2016.72,6.2C', '6.6,d2016.73', '7.1,d2016.74'])
+		tf2 = self._tf(['d2016.72,6.2C,6.1', 'd2016.73,6.6', 'd2016.74,7.1'])
+		tf3 = self._tf(['d2016.72,6.2C,6.1', '6.6,d2016.73', '7.1,d2016.74'])
+		# check without caring for output order
+		ov = "'OpenSSH': ['6.1', '6.6', '6.2', '7.1']"
+		dv = "'Dropbear SSH': ['2016.72', '2016.73', '2016.72', '2016.74']"
+		assert len(str(tf1)) == len(str(tf2)) == len(str(tf3))
+		assert ov in str(tf1) and ov in str(tf2) and ov in str(tf3)
+		assert dv in str(tf1) and dv in str(tf2) and dv in str(tf3)
+		assert ov in repr(tf1) and ov in repr(tf2) and ov in repr(tf3)
+		assert dv in repr(tf1) and dv in repr(tf2) and dv in repr(tf3)
+	
+	def test_timeframe_object(self):
+		tf = self._tf(['6.1,6.2C', '6.6', '7.1'])
+		assert 'OpenSSH' in tf
+		assert 'Dropbear SSH' not in tf
+		assert 'libssh' not in tf
+		assert 'unknown' not in tf
+		assert tf['OpenSSH'] == ('6.1', '6.6', '6.2', '7.1')
+		assert tf['Dropbear SSH'] == (None, None, None, None)
+		assert tf['libssh'] == (None, None, None, None)
+		assert tf['unknown'] == (None, None, None, None)
+		assert tf.get_from('OpenSSH', True) == '6.1'
+		assert tf.get_till('OpenSSH', True) == '6.6'
+		assert tf.get_from('OpenSSH', False) == '6.2'
+		assert tf.get_till('OpenSSH', False) == '7.1'
+		
+		tf = self._tf(['6.1,d2016.72,6.2C', '6.6,d2016.73', '7.1,d2016.74'])
+		assert 'OpenSSH' in tf
+		assert 'Dropbear SSH' in tf
+		assert 'libssh' not in tf
+		assert 'unknown' not in tf
+		assert tf['OpenSSH'] == ('6.1', '6.6', '6.2', '7.1')
+		assert tf['Dropbear SSH'] == ('2016.72', '2016.73', '2016.72', '2016.74')
+		assert tf['libssh'] == (None, None, None, None)
+		assert tf['unknown'] == (None, None, None, None)
+		assert tf.get_from('OpenSSH', True) == '6.1'
+		assert tf.get_till('OpenSSH', True) == '6.6'
+		assert tf.get_from('OpenSSH', False) == '6.2'
+		assert tf.get_till('OpenSSH', False) == '7.1'
+		assert tf.get_from('Dropbear SSH', True) == '2016.72'
+		assert tf.get_till('Dropbear SSH', True) == '2016.73'
+		assert tf.get_from('Dropbear SSH', False) == '2016.72'
+		assert tf.get_till('Dropbear SSH', False) == '2016.74'
+		ov = "'OpenSSH': ['6.1', '6.6', '6.2', '7.1']"
+		dv = "'Dropbear SSH': ['2016.72', '2016.73', '2016.72', '2016.74']"
+		assert ov in str(tf)
+		assert dv in str(tf)
+		assert ov in repr(tf)
+		assert dv in repr(tf)
diff --git a/test/test_utils.py b/test/test_utils.py
new file mode 100644
index 0000000..2a83bd8
--- /dev/null
+++ b/test/test_utils.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+import sys
+import pytest
+
+
+# pylint: disable=attribute-defined-outside-init
+class TestUtils(object):
+	@pytest.fixture(autouse=True)
+	def init(self, ssh_audit):
+		self.utils = ssh_audit.Utils
+		self.PY3 = sys.version_info >= (3,)
+	
+	def test_to_bytes_py2(self):
+		if self.PY3:
+			return
+		# binary_type (native str, bytes as str)
+		assert self.utils.to_bytes('fran\xc3\xa7ais') == 'fran\xc3\xa7ais'
+		assert self.utils.to_bytes(b'fran\xc3\xa7ais') == 'fran\xc3\xa7ais'
+		# text_type (unicode)
+		assert self.utils.to_bytes(u'fran\xe7ais') == 'fran\xc3\xa7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_bytes(123)
+	
+	def test_to_bytes_py3(self):
+		if not self.PY3:
+			return
+		# binary_type (bytes)
+		assert self.utils.to_bytes(b'fran\xc3\xa7ais') == b'fran\xc3\xa7ais'
+		# text_type (native str as unicode, unicode)
+		assert self.utils.to_bytes('fran\xe7ais') == b'fran\xc3\xa7ais'
+		assert self.utils.to_bytes(u'fran\xe7ais') == b'fran\xc3\xa7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_bytes(123)
+	
+	def test_to_utext_py2(self):
+		if self.PY3:
+			return
+		# binary_type (native str, bytes as str)
+		assert self.utils.to_utext('fran\xc3\xa7ais') == u'fran\xe7ais'
+		assert self.utils.to_utext(b'fran\xc3\xa7ais') == u'fran\xe7ais'
+		# text_type (unicode)
+		assert self.utils.to_utext(u'fran\xe7ais') == u'fran\xe7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_utext(123)
+	
+	def test_to_utext_py3(self):
+		if not self.PY3:
+			return
+		# binary_type (bytes)
+		assert self.utils.to_utext(b'fran\xc3\xa7ais') == u'fran\xe7ais'
+		# text_type (native str as unicode, unicode)
+		assert self.utils.to_utext('fran\xe7ais') == 'fran\xe7ais'
+		assert self.utils.to_utext(u'fran\xe7ais') == u'fran\xe7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_utext(123)
+	
+	def test_to_ntext_py2(self):
+		if self.PY3:
+			return
+		# str (native str, bytes as str)
+		assert self.utils.to_ntext('fran\xc3\xa7ais') == 'fran\xc3\xa7ais'
+		assert self.utils.to_ntext(b'fran\xc3\xa7ais') == 'fran\xc3\xa7ais'
+		# text_type (unicode)
+		assert self.utils.to_ntext(u'fran\xe7ais') == 'fran\xc3\xa7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_ntext(123)
+	
+	def test_to_ntext_py3(self):
+		if not self.PY3:
+			return
+		# str (native str)
+		assert self.utils.to_ntext('fran\xc3\xa7ais') == 'fran\xc3\xa7ais'
+		assert self.utils.to_ntext(u'fran\xe7ais') == 'fran\xe7ais'
+		# binary_type (bytes)
+		assert self.utils.to_ntext(b'fran\xc3\xa7ais') == 'fran\xe7ais'
+		# other
+		with pytest.raises(TypeError):
+			self.utils.to_ntext(123)
+	
+	def test_is_ascii_py2(self):
+		if self.PY3:
+			return
+		# text_type (unicode)
+		assert self.utils.is_ascii(u'francais') is True
+		assert self.utils.is_ascii(u'fran\xe7ais') is False
+		# str
+		assert self.utils.is_ascii('francais') is True
+		assert self.utils.is_ascii('fran\xc3\xa7ais') is False
+		# other
+		assert self.utils.is_ascii(123) is False
+	
+	def test_is_ascii_py3(self):
+		if not self.PY3:
+			return
+		# text_type (str)
+		assert self.utils.is_ascii('francais') is True
+		assert self.utils.is_ascii(u'francais') is True
+		assert self.utils.is_ascii('fran\xe7ais') is False
+		assert self.utils.is_ascii(u'fran\xe7ais') is False
+		# other
+		assert self.utils.is_ascii(123) is False
+	
+	def test_to_ascii_py2(self):
+		if self.PY3:
+			return
+		# text_type (unicode)
+		assert self.utils.to_ascii(u'francais') == 'francais'
+		assert self.utils.to_ascii(u'fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_ascii(u'fran\xe7ais', 'ignore') == 'franais'
+		# str
+		assert self.utils.to_ascii('francais') == 'francais'
+		assert self.utils.to_ascii('fran\xc3\xa7ais') == 'fran??ais'
+		assert self.utils.to_ascii('fran\xc3\xa7ais', 'ignore') == 'franais'
+		with pytest.raises(TypeError):
+			self.utils.to_ascii(123)
+	
+	def test_to_ascii_py3(self):
+		if not self.PY3:
+			return
+		# text_type (str)
+		assert self.utils.to_ascii('francais') == 'francais'
+		assert self.utils.to_ascii(u'francais') == 'francais'
+		assert self.utils.to_ascii('fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_ascii('fran\xe7ais', 'ignore') == 'franais'
+		assert self.utils.to_ascii(u'fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_ascii(u'fran\xe7ais', 'ignore') == 'franais'
+		with pytest.raises(TypeError):
+			self.utils.to_ascii(123)
+	
+	def test_is_print_ascii_py2(self):
+		if self.PY3:
+			return
+		# text_type (unicode)
+		assert self.utils.is_print_ascii(u'francais') is True
+		assert self.utils.is_print_ascii(u'francais\n') is False
+		assert self.utils.is_print_ascii(u'fran\xe7ais') is False
+		assert self.utils.is_print_ascii(u'fran\xe7ais\n') is False
+		# str
+		assert self.utils.is_print_ascii('francais') is True
+		assert self.utils.is_print_ascii('francais\n') is False
+		assert self.utils.is_print_ascii('fran\xc3\xa7ais') is False
+		# other
+		assert self.utils.is_print_ascii(123) is False
+	
+	def test_is_print_ascii_py3(self):
+		if not self.PY3:
+			return
+		# text_type (str)
+		assert self.utils.is_print_ascii('francais') is True
+		assert self.utils.is_print_ascii('francais\n') is False
+		assert self.utils.is_print_ascii(u'francais') is True
+		assert self.utils.is_print_ascii(u'francais\n') is False
+		assert self.utils.is_print_ascii('fran\xe7ais') is False
+		assert self.utils.is_print_ascii(u'fran\xe7ais') is False
+		# other
+		assert self.utils.is_print_ascii(123) is False
+	
+	def test_to_print_ascii_py2(self):
+		if self.PY3:
+			return
+		# text_type (unicode)
+		assert self.utils.to_print_ascii(u'francais') == 'francais'
+		assert self.utils.to_print_ascii(u'francais\n') == 'francais?'
+		assert self.utils.to_print_ascii(u'fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_print_ascii(u'fran\xe7ais\n') == 'fran?ais?'
+		assert self.utils.to_print_ascii(u'fran\xe7ais', 'ignore') == 'franais'
+		assert self.utils.to_print_ascii(u'fran\xe7ais\n', 'ignore') == 'franais'
+		# str
+		assert self.utils.to_print_ascii('francais') == 'francais'
+		assert self.utils.to_print_ascii('francais\n') == 'francais?'
+		assert self.utils.to_print_ascii('fran\xc3\xa7ais') == 'fran??ais'
+		assert self.utils.to_print_ascii('fran\xc3\xa7ais\n') == 'fran??ais?'
+		assert self.utils.to_print_ascii('fran\xc3\xa7ais', 'ignore') == 'franais'
+		assert self.utils.to_print_ascii('fran\xc3\xa7ais\n', 'ignore') == 'franais'
+		with pytest.raises(TypeError):
+			self.utils.to_print_ascii(123)
+	
+	def test_to_print_ascii_py3(self):
+		if not self.PY3:
+			return
+		# text_type (str)
+		assert self.utils.to_print_ascii('francais') == 'francais'
+		assert self.utils.to_print_ascii('francais\n') == 'francais?'
+		assert self.utils.to_print_ascii(u'francais') == 'francais'
+		assert self.utils.to_print_ascii(u'francais\n') == 'francais?'
+		assert self.utils.to_print_ascii('fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_print_ascii('fran\xe7ais\n') == 'fran?ais?'
+		assert self.utils.to_print_ascii('fran\xe7ais', 'ignore') == 'franais'
+		assert self.utils.to_print_ascii('fran\xe7ais\n', 'ignore') == 'franais'
+		assert self.utils.to_print_ascii(u'fran\xe7ais') == 'fran?ais'
+		assert self.utils.to_print_ascii(u'fran\xe7ais\n') == 'fran?ais?'
+		assert self.utils.to_print_ascii(u'fran\xe7ais', 'ignore') == 'franais'
+		assert self.utils.to_print_ascii(u'fran\xe7ais\n', 'ignore') == 'franais'
+		with pytest.raises(TypeError):
+			self.utils.to_print_ascii(123)
+	
+	def test_ctoi(self):
+		assert self.utils.ctoi(123) == 123
+		assert self.utils.ctoi('ABC') == 65
+	
+	def test_parse_int(self):
+		assert self.utils.parse_int(123) == 123
+		assert self.utils.parse_int('123') == 123
+		assert self.utils.parse_int(-123) == -123
+		assert self.utils.parse_int('-123') == -123
+		assert self.utils.parse_int('abc') == 0
+	
+	def test_unique_seq(self):
+		assert self.utils.unique_seq((1, 2, 2, 3, 3, 3)) == (1, 2, 3)
+		assert self.utils.unique_seq((3, 3, 3, 2, 2, 1)) == (3, 2, 1)
+		assert self.utils.unique_seq([1, 2, 2, 3, 3, 3]) == [1, 2, 3]
+		assert self.utils.unique_seq([3, 3, 3, 2, 2, 1]) == [3, 2, 1]
diff --git a/test/test_version_compare.py b/test/test_version_compare.py
index d3f8554..b5c4a1f 100644
--- a/test/test_version_compare.py
+++ b/test/test_version_compare.py
@@ -200,7 +200,7 @@ class TestVersionCompare(object):
 			versions.append('0.5.{0}'.format(i))
 		for i in range(0, 6):
 			versions.append('0.6.{0}'.format(i))
-		for i in range(0, 4):
+		for i in range(0, 5):
 			versions.append('0.7.{0}'.format(i))
 		l = len(versions)
 		for i in range(l):
diff --git a/test/tools/ci-linux.sh b/test/tools/ci-linux.sh
new file mode 100755
index 0000000..0bb0253
--- /dev/null
+++ b/test/tools/ci-linux.sh
@@ -0,0 +1,412 @@
+#!/bin/sh
+
+CI_VERBOSE=1
+
+ci_err_msg() { echo "[ci] error: $1" >&2; }
+ci_err() { [ $1 -ne 0 ] && ci_err_msg "$2" && exit 1; }
+ci_is_osx() { [ X"$(uname -s)" == X"Darwin" ]; }
+
+ci_get_pypy_ver() {
+	local _v="$1"
+	[ -z "$_v" ] && _v=$(python -V 2>&1)
+	case "$_v" in
+		pypy-*|pypy2-*|pypy3-*|pypy3.*) echo "$_v"; return 0 ;;
+		pypy|pypy2|pypy3) echo "$_v-unknown"; return 0 ;;
+	esac
+	echo "$_v" | tail -1 | grep -qi pypy
+	if [ $? -eq 0 ]; then
+		local _py_ver=$(echo "$_v" | head -1 | cut -d ' ' -sf 2)
+		local _pypy_ver=$(echo "$_v" | tail -1 | cut -d ' ' -sf 2)
+		[ -z "${_py_ver} " ] && _py_ver=2
+		[ -z "${_pypy_ver}" ] && _pypy_ver="unknown"
+		case "${_py_ver}" in
+			2*) echo "pypy-${_pypy_ver}" ;;
+			3.3*) echo "pypy3.3-${_pypy_ver}" ;;
+			3.5*) echo "pypy3.5-${_pypy_ver}" ;;
+			*) echo "pypy3-${_pypy_ver}" ;;
+		esac
+		return 0
+	else
+		return 1
+	fi
+}
+
+ci_get_py_ver() {
+	local _v
+	case "$1" in
+		py26) _v=2.6.9 ;;
+		py27) _v=2.7.13 ;;
+		py33) _v=3.3.6 ;;
+		py34) _v=3.4.6 ;;
+		py35) _v=3.5.3 ;;
+		py36) _v=3.6.1 ;;
+		py37) _v=3.7-dev ;;
+		pypy) ci_is_osx && _v=pypy2-5.7.0 || _v=pypy-portable-5.7.0 ;;
+		pypy3) ci_is_osx && _v=pypy3.3-5.5-alpha || _v=pypy3-portable-5.7.0 ;;
+		*)
+			[ -z "$1" ] && set -- "$(python -V 2>&1)"
+			_v=$(ci_get_pypy_ver "$1")
+			[ -z "$_v" ] && _v=$(echo "$_v" | head -1 | cut -d ' ' -sf 2)
+			;;
+	esac
+	echo "${_v}"
+	return 0
+}
+
+ci_get_py_env() {
+	[ -z "$1" ] && set -- "$(python -V 2>&1)"
+	case "$(ci_get_pypy_ver "$1")" in
+		pypy|pypy2|pypy-*|pypy2-*) echo "pypy" ;;
+		pypy3|pypy3*) echo "pypy3" ;;
+		*)
+			local _v=$(echo "$1" | head -1 | sed -e 's/[^0-9]//g' | cut -c1-2)
+			echo "py${_v}"
+	esac
+	return 0
+}
+
+ci_pyenv_setup() {
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] install pyenv"
+	rm -rf ~/.pyenv
+	git clone --depth 1 https://github.com/yyuu/pyenv.git ~/.pyenv
+	PYENV_ROOT=$HOME/.pyenv
+	PATH="$HOME/.pyenv/bin:$PATH"
+	eval "$(pyenv init -)"
+	ci_err $? "failed to init pyenv"
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv init: $(pyenv -v 2>&1)"
+	return 0
+}
+
+ci_pyenv_install() {
+	CI_PYENV_CACHE=~/.pyenv.cache
+	type pyenv > /dev/null 2>&1
+	ci_err $? "pyenv not found"
+	local _py_ver=$(ci_get_py_ver "$1")
+	local _py_env=$(ci_get_py_env "${_py_ver}")
+	local _nocache
+	case "${_py_env}" in
+		py37) _nocache=1 ;;
+	esac
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv install: ${_py_env}/${_py_ver}"
+	[ -z "${PYENV_ROOT}" ] && PYENV_ROOT="$HOME/.pyenv"
+	local _py_ver_dir="${PYENV_ROOT}/versions/${_py_ver}"
+	local _py_ver_cached_dir="${CI_PYENV_CACHE}/${_py_ver}"
+	if [ -z "${_nocache}" ]; then
+		if [ ! -d "${_py_ver_dir}" ]; then
+			if [ -d "${_py_ver_cached_dir}" ]; then
+				[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv reuse ${_py_ver}"
+				ln -s "${_py_ver_cached_dir}" "${_py_ver_dir}"
+			fi
+		fi
+	fi
+	if [ ! -d "${_py_ver_dir}" ]; then
+		pyenv install -s "${_py_ver}"
+		ci_err $? "pyenv failed to install ${_py_ver}"
+		if [ -z "${_nocache}" ]; then
+			[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv cache ${_py_ver}"
+			rm -rf -- "${_py_ver_cached_dir}"
+			mkdir -p -- "${CI_PYENV_CACHE}"
+			mv "${_py_ver_dir}" "${_py_ver_cached_dir}"
+			ln -s "${_py_ver_cached_dir}" "${_py_ver_dir}"
+		fi
+	fi
+	pyenv rehash
+	return 0
+}
+
+ci_pyenv_use() {
+	type pyenv > /dev/null 2>&1
+	ci_err $? "pyenv not found"
+	local _py_ver=$(ci_get_py_ver "$1")
+	pyenv shell "${_py_ver}"
+	ci_err $? "pyenv could not use ${_py_ver}"
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] pyenv using python: $(python -V 2>&1)"
+	return 0
+}
+
+ci_pip_setup() {
+	local _py_ver=$(ci_get_py_ver "$1")
+	local _py_env=$(ci_get_py_env "${_py_ver}")
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] install pip/venv for ${_py_env}/${_py_ver}"
+	PIPOPT=$(python -c 'import sys; print("" if hasattr(sys, "real_prefix") else "--user")')
+	if [ -z "${_py_env##py2*}" ]; then
+		curl -O https://bootstrap.pypa.io/get-pip.py
+		python get-pip.py ${PIPOPT}
+		ci_err $? "failed to install pip"
+	fi
+	if [ X"${_py_env}" == X"py26" ]; then
+	  python -c 'import pip; pip.main();' install ${PIPOPT} -U pip virtualenv
+	else
+	  python -m pip install ${PIPOPT} -U pip virtualenv
+	fi
+	ci_err $? "failed to upgrade pip/venv" || return 0
+}
+
+ci_venv_setup() {
+	local _py_ver=$(ci_get_py_ver "$1")
+	local _py_env=$(ci_get_py_env "${_py_ver}")
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] create venv for ${_py_env}/${_py_ver}"
+	local VENV_DIR=~/.venv/${_py_ver}
+	mkdir -p -- ~/.venv
+	rm -rf -- "${VENV_DIR}"
+	if [ X"${_py_env}" == X"py26" ]; then
+	  python -c 'import virtualenv; virtualenv.main();' "${VENV_DIR}"
+	else
+	  python -m virtualenv "${VENV_DIR}"
+	fi
+	ci_err $? "failed to create venv" || return 0
+}
+
+ci_venv_use() {
+	local _py_ver=$(ci_get_py_ver "$1")
+	local _py_env=$(ci_get_py_env "${_py_ver}")
+	local VENV_DIR=~/.venv/${_py_ver}
+	. "${VENV_DIR}/bin/activate"
+	ci_err $? "could not actiavte virtualenv"
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] venv using python: $(python -V 2>&1)"
+	return 0
+}
+
+ci_get_filedir() {
+	local _sdir=$(cd -- "$(dirname "$0")" && pwd)
+	local _pdir=$(pwd)
+	if [ -z "${_pdir##${_sdir}*}" ]; then
+		_sdir="${_pdir}"
+	fi
+	local _first=1
+	while [ X"${_sdir}" != X"/" ]; do
+		if [ ${_first} -eq 1 ]; then
+			_first=0
+			local _f=$(find "${_sdir}" -name "$1" | head -1)
+			if [ -n "${_f}" ]; then
+				echo $(dirname -- "${_f}")
+				return 0
+			fi
+		else
+			_f=$(find "${_sdir}" -mindepth 1 -maxdepth 1 -name "$1" | head -1)
+		fi
+		[ -n "${_f}" ] && echo "${_sdir}" && return 0
+		_sdir=$(cd -- "${_sdir}/.." && pwd)
+	done
+	return 1
+}
+
+ci_sq_ensure_java() {
+	type java >/dev/null 2>&1
+	if [ $? -ne 0 ]; then
+		ci_err_msg "java not found"
+		return 1
+	fi
+	local _java_ver=$(java -version 2>&1 | head -1 | sed -e 's/[^0-9\._]//g')
+	if [ -z "${_java_ver##1.8*}" ]; then
+		return 0
+	fi
+	ci_err_msg "unsupported java version: ${_java_ver}"
+	return 1
+}
+
+ci_sq_ensure_scanner() {
+	local _cli_version="3.0.0.702"
+	local _cli_basedir="$HOME/.bin"
+	local _cli_postfix=""
+	case "$(uname -s)" in
+		Linux)
+			[ X"$(uname -m)" = X"x86_64" ] && _cli_postfix="-linux"
+			[ X"$(uname -m)" = X"amd64" ] && _cli_postfix="-linux"
+			;;
+		Darwin) _cli_postfix="-macosx" ;;
+	esac
+	if [ X"${_cli_postfix}" = X"" ]; then
+		ci_sq_ensure_java || return 1
+	fi
+	if [ X"${SONAR_SCANNER_PATH}" != X"" ]; then
+		if [ -e "${SONAR_SCANNER_PATH}" ]; then
+			return 0
+		fi
+	fi
+	local _cli_fname="sonar-scanner-cli-${_cli_version}${_cli_postfix}"
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] ensure scanner ${_cli_fname}"
+	local _cli_dname="sonar-scanner-${_cli_version}${_cli_postfix}"
+	local _cli_archive="${_cli_basedir}/${_cli_fname}.zip"
+	local _cli_dir="${_cli_basedir}/${_cli_dname}"
+	local _cli_url="https://sonarsource.bintray.com/Distribution/sonar-scanner-cli/${_cli_fname}.zip"
+	if [ ! -e "${_cli_archive}" ]; then
+		mkdir -p -- "${_cli_basedir}" > /dev/null 2>&1
+		if [ $? -ne 0 ]; then
+			ci_err_msg "could not create ${_cli_basedir}"
+			return 1
+		fi
+		[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] downloading ${_cli_fname}"
+		curl -kL -o "${_cli_archive}" "${_cli_url}"
+		[ $? -ne 0 ] && ci_err_msg "download failed" && return 1
+		[ ! -e "${_cli_archive}" ] && ci_err_msg "download verify" && return 1
+	fi
+	if [ ! -d "${_cli_dir}" ]; then
+		[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] extracting ${_cli_fname}"
+		unzip -od "${_cli_basedir}" "${_cli_archive}"
+		[ $? -ne 0 ] && ci_err_msg "extract failed" && return 1
+		[ ! -d "${_cli_dir}" ] && ci_err_msg "extract verify" && return 1
+	fi
+	if [ ! -e "${_cli_dir}/bin/sonar-scanner" ]; then
+		ci_err_msg "sonar-scanner binary not found."
+		return 1
+	fi
+	SONAR_SCANNER_PATH="${_cli_dir}/bin/sonar-scanner"
+	return 0
+}
+
+ci_sq_run() {
+	if [ X"${SONAR_SCANNER_PATH}" = X"" ]; then
+		ci_err_msg "environment variable SONAR_SCANNER_PATH not set"
+		return 1
+	fi
+	if [ X"${SONAR_HOST_URL}" = X"" ]; then
+		ci_err_msg "environment variable SONAR_HOST_URL not set"
+		return 1
+	fi
+	if [ X"${SONAR_AUTH_TOKEN}" = X"" ]; then
+		ci_err_msg "environment variable SONAR_AUTH_TOKEN not set"
+		return 1
+	fi
+	local _pdir=$(ci_get_filedir "ssh-audit.py")
+	if [ -z "${_pdir}" ]; then
+		ci_err_msg "failed to find project directory"
+		return 1
+	fi
+	local _odir=$(pwd)
+	cd -- "${_pdir}"
+	local _branch=$(git name-rev --name-only HEAD | cut -d '~' -f 1)
+	case "${_branch}" in
+		master) ;;
+		develop) ;;
+		*) ci_err_msg "unknown branch: ${_branch}"; return 1 ;;
+	esac
+	local _junit=$(cd -- "${_pdir}" && ls -1 reports/junit.*.xml | sort -r | head -1)
+	if [ X"${_junit}" = X"" ]; then
+		ci_err_msg "no junit.xml found"
+		return 1
+	fi
+	local _project_ver=$(grep VERSION ssh-audit.py | head -1 | cut -d "'" -f 2)
+	if [ -z "${_project_ver}" ]; then
+		ci_err_msg "failed to get project version"
+		return 1
+	fi
+	if [ -z "${_project_ver##*dev}" ]; then
+		local _git_commit=$(git rev-parse --short=8 HEAD)
+		_project_ver="${_project_ver}.${_git_commit}"
+	fi
+	[ ${CI_VERBOSE} -gt 0 ] && echo "[ci] run sonar-scanner for ${_project_ver}"
+	"${SONAR_SCANNER_PATH}" -X \
+		-Dsonar.projectKey=arthepsy-github:ssh-audit \
+		-Dsonar.sources=ssh-audit.py \
+		-Dsonar.tests=test \
+		-Dsonar.test.inclusions=test/*.py \
+		-Dsonar.host.url="${SONAR_HOST_URL}" \
+		-Dsonar.projectName=ssh-audit \
+		-Dsonar.projectVersion="${_project_ver}" \
+		-Dsonar.branch="${_branch}" \
+		-Dsonar.python.coverage.overallReportPath=reports/coverage.xml \
+		-Dsonar.python.xunit.reportPath="${_junit}" \
+		-Dsonar.organization=arthepsy-github \
+		-Dsonar.login="${SONAR_AUTH_TOKEN}"
+	cd -- "${_odir}"
+	return 0
+}
+
+ci_run_wrapped() {
+	local _versions=$(echo "${PY_VER}" | sed -e 's/,/ /g')
+	[ -z "${_versions}" ] && eval "$1"
+	for _i in ${_versions}; do
+		local _v=$(echo "$_i" | cut -d '/' -f 1)
+		local _o=$(echo "$_i" | cut -d '/' -sf 2)
+		[ -z "${_o}" ] && _o="${PY_ORIGIN}"
+		eval "$1" "${_v}" "${_o}" || return 1
+	done
+	return 0
+}
+
+ci_step_before_install_wrapped() {
+	local _py_ver="$1"
+	local _py_ori="$2"
+	case "${_py_ori}" in
+		pyenv)
+			if [ "${CI_PYENV_SETUP}" -eq 0 ]; then
+				ci_pyenv_setup
+				CI_PYENV_SETUP=1
+			fi
+			ci_pyenv_install "${_py_ver}" || return 1
+			ci_pyenv_use "${_py_ver}" || return 1
+			;;
+	esac
+	ci_pip_setup "${_py_ver}" || return 1
+	ci_venv_setup "${_py_ver}" || return 1
+	return 0
+}
+
+ci_step_before_install() {
+	if ci_is_osx; then
+		[ ${CI_VERBOSE} -gt 0 ] && sw_vers
+		brew update || brew update
+		brew install autoconf pkg-config openssl readline xz
+		brew upgrade autoconf pkg-config openssl readline xz
+		PY_ORIGIN=pyenv
+	fi
+	CI_PYENV_SETUP=0
+	ci_run_wrapped "ci_step_before_install_wrapped" || return 1
+	if [ "${CI_PYENV_SETUP}" -eq 1 ]; then
+		pyenv shell --unset
+		[ ${CI_VERBOSE} -gt 0 ] && pyenv versions
+	fi
+	return 0
+}
+
+ci_step_install_wrapped() {
+	local _py_ver="$1"
+	ci_venv_use "${_py_ver}"
+	pip install -U tox coveralls codecov
+	ci_err $? "failed to install dependencies" || return 0
+}
+
+ci_step_script_wrapped() {
+	local _py_ver="$1"
+	local _py_ori="$2"
+	local _py_env=$(ci_get_py_env "${_py_ver}")
+	ci_venv_use "${_py_ver}" || return 1
+	if [ -z "${_py_env##*py3*}" ]; then
+		if [ -z "${_py_env##*pypy3*}" ]; then
+			# NOTE: workaround for travis environment
+			_pydir=$(dirname $(which python))
+			ln -s -- "${_pydir}/python" "${_pydir}/pypy3"
+			# NOTE: do not lint, as it hangs when flake8 is run
+			# NOTE: do not type, as it can't install dependencies
+			TOXENV=${_py_env}-test
+		else
+			TOXENV=${_py_env}-test,${_py_env}-type,${_py_env}-lint
+		fi
+	else
+		# NOTE: do not type, as it isn't supported on py2x
+		TOXENV=${_py_env}-test,${_py_env}-lint
+	fi
+	tox -e $TOXENV,cov
+	ci_err $? "tox failed" || return 0
+}
+
+ci_step_success_wrapped() {
+	local _py_ver="$1"
+	local _py_ori="$2"
+	if [ X"${SQ}" = X"1" ]; then
+		ci_sq_ensure_scanner && ci_sq_run
+	fi
+	ci_venv_use "${_py_ver}" || return 1
+	coveralls
+	codecov
+}
+
+ci_step_failure() { 
+	cat .tox/log/*
+	cat .tox/*/log/*
+}
+
+ci_step_install() { ci_run_wrapped "ci_step_install_wrapped"; }
+ci_step_script() { ci_run_wrapped "ci_step_script_wrapped"; }
+ci_step_success() { ci_run_wrapped "ci_step_success_wrapped"; }
diff --git a/test/tools/ci-win.cmd b/test/tools/ci-win.cmd
new file mode 100644
index 0000000..103036c
--- /dev/null
+++ b/test/tools/ci-win.cmd
@@ -0,0 +1,131 @@
+@ECHO OFF
+
+IF "%PYTHON%" == "" (
+	ECHO PYTHON environment variable not set
+	EXIT 1
+)
+SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
+FOR /F %%i IN ('python -c "import platform; print(platform.python_version());"') DO (
+	SET PYTHON_VERSION=%%i
+)
+SET PYTHON_VERSION_MAJOR=%PYTHON_VERSION:~0,1%
+IF "%PYTHON_VERSION:~3,1%" == "." (
+	SET PYTHON_VERSION_MINOR=%PYTHON_VERSION:~2,1%
+) ELSE (
+	SET PYTHON_VERSION_MINOR=%PYTHON_VERSION:~2,2%
+)
+FOR /F %%i IN ('python -c "import struct; print(struct.calcsize(\"P\")*8)"') DO (
+	SET PYTHON_ARCH=%%i
+)
+CALL :devenv
+
+IF /I "%1"=="" (
+	SET target=test
+) ELSE (
+	SET target=%1
+)
+
+echo [CI] TARGET=%target%
+GOTO %target%
+
+:devenv
+SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows
+SET VS2015_ROOT=C:\Program Files (x86)\Microsoft Visual Studio 14.0
+IF %PYTHON_VERSION_MAJOR% == 2 (
+	SET WINDOWS_SDK_VERSION="v7.0"
+) ELSE IF %PYTHON_VERSION_MAJOR% == 3 (
+	IF %PYTHON_VERSION_MAJOR% LEQ 4 (
+		SET WINDOWS_SDK_VERSION="v7.1"
+	) ELSE (
+		SET WINDOWS_SDK_VERSION="2015"
+	)
+) ELSE (
+	ECHO Unsupported Python version: "%PYTHON_VERSION%"
+	EXIT 1
+)
+SETLOCAL ENABLEDELAYEDEXPANSION
+IF %PYTHON_ARCH% == 32 (SET PYTHON_ARCHX=x86) ELSE (SET PYTHON_ARCHX=x64)
+IF %WINDOWS_SDK_VERSION% == "2015" (
+	"%VS2015_ROOT%\VC\vcvarsall.bat" %PYTHON_ARCHX%
+) ELSE (
+	SET DISTUTILS_USE_SDK=1
+	SET MSSdk=1
+	"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION%
+	"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /%PYTHON_ARCHX% /release
+)
+GOTO :eof
+
+:install
+pip install --user --upgrade pip virtualenv
+SET VENV_DIR=.venv\%PYTHON_VERSION%
+rmdir /s /q %VENV_DIR% > nul 2>nul
+mkdir .venv > nul 2>nul
+IF "%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR%" == "26" (
+	python -c "import virtualenv; virtualenv.main();" %VENV_DIR%
+) ELSE (
+	python -m virtualenv %VENV_DIR%
+)
+CALL %VENV_DIR%\Scripts\activate
+python -V
+pip install tox
+deactivate
+GOTO :eof
+
+:install_deps
+SET LXML_FILE=
+SET LXML_URL=
+IF %PYTHON_VERSION_MAJOR% == 3 (
+	IF %PYTHON_VERSION_MINOR% == 3 (
+		IF %PYTHON_ARCH% == 32 (
+			SET LXML_FILE=lxml-3.7.3.win32-py3.3.exe
+			SET LXML_URL=https://pypi.python.org/packages/66/fd/b82a54e7a15e91184efeef4b659379d0581a73cf78239d70feb0f0877841/lxml-3.7.3.win32-py3.3.exe
+		) ELSE (
+			SET LXML_FILE=lxml-3.7.3.win-amd64-py3.3.exe
+			SET LXML_URL=https://pypi.python.org/packages/dc/bc/4742b84793fa1fd991b5d2c6f2e5d32695659d6cfedf5c66aef9274a8723/lxml-3.7.3.win-amd64-py3.3.exe
+		)
+	) ELSE IF %PYTHON_VERSION_MINOR% == 4 (
+		IF %PYTHON_ARCH% == 32 (
+			SET LXML_FILE=lxml-3.7.3.win32-py3.4.exe
+			SET LXML_URL=https://pypi.python.org/packages/88/33/265459d68d465ddc707621e6471989f5c2cb0d43f230f516800ffd629af7/lxml-3.7.3.win32-py3.4.exe
+		) ELSE (
+			SET LXML_FILE=lxml-3.7.3.win-amd64-py3.4.exe
+			SET LXML_URL=https://pypi.python.org/packages/2d/65/e47db7f36a69a1b59b4f661e42d699d6c43e663b8fd91035e6f7681d017e/lxml-3.7.3.win-amd64-py3.4.exe
+		)
+	)
+)
+IF NOT "%LXML_FILE%" == "" (
+	CALL :download %LXML_URL% .downloads\%LXML_FILE%
+	easy_install --user .downloads\%LXML_FILE%
+)
+GOTO :eof
+
+:test
+	SET VENV_DIR=.venv\%PYTHON_VERSION%
+	CALL %VENV_DIR%\Scripts\activate
+	IF "%TOXENV%" == "" (
+		SET TOXENV=py%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR%
+	)
+	IF "%PYTHON_VERSION_MAJOR%%PYTHON_VERSION_MINOR%" == "26" (
+		SET TOX=python -c "from tox import cmdline; cmdline()"
+	) ELSE (
+		SET TOX=python -m tox
+	)
+	IF %PYTHON_VERSION_MAJOR% == 3 (
+		IF %PYTHON_VERSION_MINOR% LEQ 4 (
+			:: Python 3.3 and 3.4 does not support typed-ast (mypy dependency)
+			%TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-lint,cov || EXIT 1
+		) ELSE (
+			%TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-type,%TOXENV%-lint,cov || EXIT 1
+		)
+	) ELSE (
+		%TOX% --sitepackages -e %TOXENV%-test,%TOXENV%-lint,cov || EXIT 1
+	)
+GOTO :eof
+
+:download
+IF NOT EXIST %2 (
+	IF NOT EXIST .downloads\ mkdir .downloads
+	powershell -command "(new-object net.webclient).DownloadFile('%1', '%2')" || EXIT 1
+
+)
+GOTO :eof
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..7f61a11
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,158 @@
+[tox]
+envlist = 
+	py26-{test,vulture}
+	py{27,py,py3}-{test,pylint,flake8,vulture}
+	py{33,34,35,36,37}-{test,mypy,pylint,flake8,vulture}
+	cov
+skipsdist = true
+skip_missing_interpreters = true
+
+[testenv]
+deps = 
+	test: pytest==3.0.7
+	test,cov: {[testenv:cov]deps}
+	test,py{33,34,35,36,37}-{type,mypy}: colorama==0.3.7
+	py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]deps}
+	py{27,py,py3,33,34,35,36,37}-{lint,pylint},lint: {[testenv:pylint]deps}
+	py{27,py,py3,33,34,35,36,37}-{lint,flake8},lint: {[testenv:flake8]deps}
+	py{27,py,py3,33,34,35,36,37}-{lint,vulture},lint: {[testenv:vulture]deps}
+setenv =
+	SSHAUDIT = {toxinidir}/ssh-audit.py
+	test: COVERAGE_FILE = {toxinidir}/.coverage.{envname}
+	type,mypy: MYPYPATH = {toxinidir}/test/stubs
+	type,mypy: MYPYHTML = {toxinidir}/reports/html/mypy
+commands =
+	test: coverage run --source ssh-audit -m -- \
+	test: pytest -v --junitxml={toxinidir}/reports/junit.{envname}.xml {posargs:test}
+	test: coverage report --show-missing
+	test: coverage html -d {toxinidir}/reports/html/coverage.{envname}
+	py{33,34,35,36,37}-{type,mypy}: {[testenv:mypy]commands}
+	py{27,py,py3,33,34,35,36,37}-{lint,pylint},lint: {[testenv:pylint]commands}
+	py{27,py,py3,33,34,35,36,37}-{lint,flake8},lint: {[testenv:flake8]commands}
+	py{27,py,py3,33,34,35,36,37}-{lint,vulture},lint: {[testenv:vulture]commands}
+ignore_outcome =
+	type: true
+	lint: true
+
+[testenv:cov]
+deps =
+	coverage==4.3.4
+setenv =
+	COVERAGE_FILE = {toxinidir}/.coverage
+commands =
+	coverage erase
+	coverage combine
+	coverage report --show-missing
+	coverage xml -i -o {toxinidir}/reports/coverage.xml
+	coverage html -d {toxinidir}/reports/html/coverage
+
+[testenv:mypy]
+deps =
+	colorama==0.3.7
+	lxml==3.7.3
+	mypy==0.501
+commands =
+	mypy \
+		--show-error-context \
+		--config-file {toxinidir}/tox.ini \
+		--html-report {env:MYPYHTML}.py3.{envname} \
+		{posargs:{env:SSHAUDIT}}
+	mypy \
+		-2 \
+		--no-warn-incomplete-stub \
+		--show-error-context \
+		--config-file {toxinidir}/tox.ini \
+		--html-report {env:MYPYHTML}.py2.{envname} \
+		{posargs:{env:SSHAUDIT}}
+
+[testenv:pylint]
+deps =
+	mccabe
+	pylint
+commands =
+	pylint \
+		--rcfile tox.ini \
+		--load-plugins=pylint.extensions.bad_builtin \
+		--load-plugins=pylint.extensions.check_elif \
+		--load-plugins=pylint.extensions.mccabe \
+		{posargs:{env:SSHAUDIT}}
+
+[testenv:flake8]
+deps =
+	flake8
+commands =
+	flake8 {posargs:{env:SSHAUDIT}}
+
+[testenv:vulture]
+deps =
+	vulture
+commands =
+	python -c "import sys; from subprocess import Popen, PIPE; \
+		a = ['vulture'] + r'{posargs:{env:SSHAUDIT}}'.split(' '); \
+		o = Popen(a, shell=False, stdout=PIPE).communicate()[0]; \
+		l = [x for x in o.split(b'\n') if x and b'Unused import' not in x]; \
+		print(b'\n'.join(l).decode('utf-8')); \
+		sys.exit(1 if len(l) > 0 else 0)"
+
+
+[mypy]
+ignore_missing_imports = False
+follow_imports = error
+disallow_untyped_calls = True
+disallow_untyped_defs = True
+check_untyped_defs = True
+disallow_subclassing_any = True
+warn_incomplete_stub = True
+warn_redundant_casts = True
+warn_return_any = True
+warn_unused_ignores = True
+strict_optional = True
+strict_boolean = True
+
+[pylint]
+reports = no
+#output-format = colorized
+indent-string = \t
+disable = 
+	locally-disabled,
+	bad-continuation,
+	multiple-imports,
+	invalid-name,
+	trailing-whitespace,
+	missing-docstring
+max-complexity = 15
+max-args = 8
+max-locals = 20
+max-returns = 6
+max-branches = 15
+max-statements = 60
+max-parents = 7
+max-attributes = 8
+min-public-methods = 1
+max-public-methods = 20
+max-bool-expr = 5
+max-nested-blocks = 6
+max-line-length = 80
+ignore-long-lines = ^\s*(#\s+type:\s+.*|[A-Z0-9_]+\s+=\s+.*|('.*':\s+)?\[.*\],?|assert\s+.*)$
+max-module-lines = 2500
+
+[flake8]
+ignore =
+	# indentation contains tabs
+	W191,
+	# blank line contains whitespace
+	W293,
+	# indentation contains mixed spaces and tabs
+	E101,
+	# multiple spaces before operator
+	E221,
+	# multiple spaces after operator
+	E241,
+	# multiple imports on one line
+	E401,
+	# line too long
+	E501,
+	# module imported but unused
+	F401,
+	# undefined name
+	F821