diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8ddf8fb..86c2178 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 0.11.2 +current_version = 0.11.3 [bumpversion:file:setup.py] diff --git a/.pylintrc b/.pylintrc index 031341d..ed703f3 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,5 @@ [MESSAGES CONTROL] -disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return,fixme +disable=invalid-name, missing-docstring, locally-disabled, unbalanced-tuple-unpacking,no-else-return,fixme,duplicate-code [SIMILARITIES] min-similarity-lines=5 diff --git a/.travis.yml b/.travis.yml index 6458abd..fb61b07 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ sudo: false language: python python: - - "2.7" - - "3.4" - "3.5" - "3.6" diff --git a/agents/trezor/setup.py b/agents/trezor/setup.py index 6671d9d..44d1d2e 100644 --- a/agents/trezor/setup.py +++ b/agents/trezor/setup.py @@ -3,15 +3,15 @@ from setuptools import setup setup( name='trezor_agent', - version='0.9.2', + version='0.9.3', description='Using Trezor as hardware SSH/GPG agent', author='Roman Zeyde', author_email='roman.zeyde@gmail.com', url='http://github.com/romanz/trezor-agent', scripts=['trezor_agent.py'], install_requires=[ - 'libagent>=0.9.0', - 'trezor>=0.9.0' + 'libagent>=0.11.2', + 'trezor[hidapi]>=0.9.0' ], platforms=['POSIX'], classifiers=[ diff --git a/doc/DESIGN.md b/doc/DESIGN.md index 28bc064..598899c 100644 --- a/doc/DESIGN.md +++ b/doc/DESIGN.md @@ -12,11 +12,11 @@ So when you `ssh` to a machine - rather than consult the normal ssh-agent (which ## Key Naming -`trezor-agent` goes to great length to avoid using the valuable parent key. +`trezor-agent` goes to great length to avoid using the valuable parent key. -The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign). +The rationale behind this is that `trezor-agent` is to some extent condemned to *blindly* signing any NONCE given to it (e.g. as part of a challenge respone, or as the hash/hmac of someting to sign). -And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else. +And doing so with the master private key is risky - as rogue (ssh) server could possibly provide a doctored NONCE that happens to be tied to a transaction or something else. It therefore uses only derived child keys pairs instead (according to the [BIP-0032: Hierarchical Deterministic Wallets][1] system) - and ones on different leafs. So the parent key is only used within the device for creating the child keys - and not exposed in any way to `trezor-agent`. @@ -26,7 +26,7 @@ It is common for SSH users to use one (or a few) private keys with SSH on all se So taking a commmand such as: - $ trezor-agent -c user@fqdn.com + $ trezor-agent -c user@fqdn.com The `trezor-agent` will take the `user`@`fqdn.com`; canonicalise it (e.g. to add the ssh default port number if none was specified) and then apply some simple hashing (See [SLIP-0013 : Authentication using deterministic hierarchy][2]). The resulting 128bit hash is then used to construct a lead 'HD node' that contains an extened public private *child* key. @@ -42,10 +42,10 @@ Note: Keepkey does not support en-/de-cryption at this time. ### Index -The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address. +The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address. This feature is currently not used -- it is set to '0'. This may change in the future. -[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +[1]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki [2]: https://github.com/satoshilabs/slips/blob/master/slip-0013.md [3]: https://github.com/satoshilabs/slips/blob/master/slip-0017.md diff --git a/doc/INSTALL.md b/doc/INSTALL.md index ccb63b6..521714f 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -21,7 +21,7 @@ You can install them on these distributions as follows: ##### Fedora - $ dnf install python3-pip python3-devel python3-tk libusb-devel libudev-devel \ + $ dnf install python3-pip python3-devel python3-tkinter libusb-devel libudev-devel \ gcc redhat-rpm-config ##### OpenSUSE @@ -33,7 +33,7 @@ If you are using python3 or your system `pip` command points to `pip3.x` dependencies instead: $ zypper install python3-pip python3-devel python3-tk libusb-1_0-devel libudev-devel - + ##### macOS There are many different options to install python environment on macOS ([official](https://www.python.org/downloads/mac-osx/), [anaconda](https://conda.io/docs/user-guide/install/macos.html), ..). Most importantly you need `libusb`. Probably the easiest way is via [homebrew](https://brew.sh/) @@ -77,6 +77,12 @@ gpg (GnuPG) 2.1.15 $ pip3 install --user -e trezor-agent/agents/trezor ``` + Or, through Homebrew on macOS: + + ``` + $ brew install trezor-agent + ``` + # 3. Install the KeepKey agent 1. Make sure you are running the latest firmware version on your KeepKey: @@ -90,6 +96,12 @@ Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_ag $ pip3 install keepkey_agent ``` + Or, on Mac using Homebrew: + + ``` + $ homebrew install keepkey-agent + ``` + Or, directly from the latest source code: ``` diff --git a/doc/README-GPG.md b/doc/README-GPG.md index bed4d34..9129dcf 100644 --- a/doc/README-GPG.md +++ b/doc/README-GPG.md @@ -70,6 +70,21 @@ $ git tag v1.2.3 --sign # create GPG-signed tag $ git tag v1.2.3 --verify # verify tag signature ``` +Note that your git email has to correlate to your gpg key email. If you use a different email for git, you'll need to either generate a new gpg key for that email or set your git email using the command: + +```` +$ git config user.email foo@example.com +```` + +If your git email is configured incorrectly, you will receive the error: + +```` +error: gpg failed to sign the data +fatal: failed to write commit object +```` + +when committing to git. + ### Manage passwords Password managers such as [pass](https://www.passwordstore.org/) and [gopass](https://www.justwatch.com/gopass/) rely on GPG for encryption so you can use your device with them too. diff --git a/doc/README-PINENTRY.md b/doc/README-PINENTRY.md index 6c90705..17aa5c8 100644 --- a/doc/README-PINENTRY.md +++ b/doc/README-PINENTRY.md @@ -9,7 +9,7 @@ $ apt install pinentry-{curses,gnome3,qt} or (on macOS): ``` -$ brew install pinentry-mac +$ brew install pinentry ``` By default a standard GPG PIN entry program is used when entering your Trezor PIN, but it's difficult to use if you don't have a numeric keypad or want to use your mouse. diff --git a/doc/README-SSH.md b/doc/README-SSH.md index 008c8a8..3101352 100644 --- a/doc/README-SSH.md +++ b/doc/README-SSH.md @@ -32,6 +32,7 @@ $ (trezor|keepkey|ledger)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS ``` to start the agent in the background and execute the command with environment variables set up to use the SSH agent. The specified identity is used for all SSH connections. The agent will exit after the command completes. +Note the `--` separator, which is used to separate `trezor-agent`'s arguments from the SSH command arguments. As a shortcut you can run @@ -161,7 +162,7 @@ export SSH_AUTH_SOCK=$(systemctl show --user --property=Listen trezor-ssh-agent. If SSH connection fails to work, please open an [issue](https://github.com/romanz/trezor-agent/issues) with a verbose log attached (by running `trezor-agent -vv`) . -##### Incompatible SSH options +##### `IdentitiesOnly` SSH option Note that your local SSH configuration may ignore `trezor-agent`, if it has `IdentitiesOnly` option set to `yes`. @@ -172,6 +173,21 @@ Note that your local SSH configuration may ignore `trezor-agent`, if it has `Ide This option is intended for situations where ssh-agent offers many different identities. The default is “no”. -If you are failing to connect, try running: +If you are failing to connect, save your public key using: - $ trezor-agent -vv user@host -- ssh -vv -oIdentitiesOnly=no user@host + $ trezor-agent -vv foobar@hostname.com > ~/.ssh/hostname.pub + +And add the following lines to `~/.ssh/config` (providing the public key explicitly to SSH): + + Host hostname.com + User foobar + IdentityFile ~/.ssh/hostname.pub + +Then, the following commands should successfully command to the remote host: + + $ trezor-agent -v foobar@hostname.com -s + $ ssh foobar@hostname.com + +or, + + $ trezor-agent -v foobar@hostname.com -c diff --git a/libagent/device/trezor.py b/libagent/device/trezor.py index 0fbfc2b..9b1f7e2 100644 --- a/libagent/device/trezor.py +++ b/libagent/device/trezor.py @@ -7,6 +7,7 @@ import mnemonic import semver from . import interface +from .. import util log = logging.getLogger(__name__) @@ -46,7 +47,7 @@ class Trezor(interface.Device): conn.callback_PinMatrixRequest = new_handler - cached_passphrase_ack = None + cached_passphrase_ack = util.ExpiringCache(seconds=float('inf')) cached_state = None def _override_passphrase_handler(self, conn): @@ -57,9 +58,10 @@ class Trezor(interface.Device): try: if msg.on_device is True: return self._defs.PassphraseAck() - if self.__class__.cached_passphrase_ack: + ack = self.__class__.cached_passphrase_ack.get() + if ack: log.debug('re-using cached %s passphrase', self) - return self.__class__.cached_passphrase_ack + return ack passphrase = self.ui.get_passphrase() passphrase = mnemonic.Mnemonic.normalize_string(passphrase) @@ -70,7 +72,7 @@ class Trezor(interface.Device): msg = 'Too long passphrase ({} chars)'.format(length) raise ValueError(msg) - self.__class__.cached_passphrase_ack = ack + self.__class__.cached_passphrase_ack.set(ack) return ack except: # noqa conn.init_device() diff --git a/libagent/device/ui.py b/libagent/device/ui.py index cdeaa91..1b9b985 100644 --- a/libagent/device/ui.py +++ b/libagent/device/ui.py @@ -24,7 +24,7 @@ class UI(object): self.options_getter = create_default_options_getter() self.device_name = device_type.__name__ - def get_pin(self): + def get_pin(self, name=None): """Ask the user for (scrambled) PIN.""" description = ( 'Use the numeric keypad to describe number positions.\n' @@ -33,16 +33,16 @@ class UI(object): ' 4 5 6\n' ' 1 2 3') return interact( - title='{} PIN'.format(self.device_name), + title='{} PIN'.format(name or self.device_name), prompt='PIN:', description=description, binary=self.pin_entry_binary, options=self.options_getter()) - def get_passphrase(self): + def get_passphrase(self, name=None): """Ask the user for passphrase.""" return interact( - title='{} passphrase'.format(self.device_name), + title='{} passphrase'.format(name or self.device_name), prompt='Passphrase:', description=None, binary=self.passphrase_entry_binary, diff --git a/libagent/gpg/__init__.py b/libagent/gpg/__init__.py index d7bd7e6..de246da 100644 --- a/libagent/gpg/__init__.py +++ b/libagent/gpg/__init__.py @@ -148,6 +148,7 @@ export PATH={0} -vv \ --pin-entry-binary={pin_entry_binary} \ --passphrase-entry-binary={passphrase_entry_binary} \ +--cache-expiry-seconds={cache_expiry_seconds} \ $* """.format(os.environ['PATH'], agent_path, **vars(args))) check_call(['chmod', '700', f.name]) @@ -179,7 +180,8 @@ fi # Generate new GPG identity and import into GPG keyring pubkey = write_file(os.path.join(homedir, 'pubkey.asc'), export_public_key(device_type, args)) - check_call(keyring.gpg_command(['--homedir', homedir, '--quiet', + verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet' + check_call(keyring.gpg_command(['--homedir', homedir, verbosity, '--import', pubkey.name])) # Make new GPG identity with "ultimate" trust (via its fingerprint) @@ -229,6 +231,8 @@ def run_agent(device_type): help='Path to PIN entry UI helper.') p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', help='Path to passphrase entry UI helper.') + p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), + help='Expire passphrase from cache after this duration.') args, _ = p.parse_known_args() @@ -245,6 +249,8 @@ def run_agent(device_type): pubkey_bytes = keyring.export_public_keys(env=env) device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) + device_type.cached_passphrase_ack = util.ExpiringCache( + seconds=float(args.cache_expiry_seconds)) handler = agent.Handler(device=device_type(), pubkey_bytes=pubkey_bytes) @@ -272,7 +278,9 @@ def run_agent(device_type): def main(device_type): """Parse command-line arguments.""" - parser = argparse.ArgumentParser() + epilog = ('See https://github.com/romanz/trezor-agent/blob/master/' + 'doc/README-GPG.md for usage examples.') + parser = argparse.ArgumentParser(epilog=epilog) agent_package = device_type.package_name() resources_map = {r.key: r for r in pkg_resources.require(agent_package)} @@ -299,6 +307,8 @@ def main(device_type): help='Path to PIN entry UI helper.') p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', help='Path to passphrase entry UI helper.') + p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), + help='Expire passphrase from cache after this duration.') p.set_defaults(func=run_init) @@ -308,5 +318,7 @@ def main(device_type): args = parser.parse_args() device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) + device_type.cached_passphrase_ack = util.ExpiringCache( + seconds=float(args.cache_expiry_seconds)) return args.func(device_type=device_type, args=args) diff --git a/libagent/gpg/agent.py b/libagent/gpg/agent.py index 92bb2d7..1898a77 100644 --- a/libagent/gpg/agent.py +++ b/libagent/gpg/agent.py @@ -92,7 +92,7 @@ class Handler(object): b'OPTION': lambda _, args: self.handle_option(*args), b'SETKEYDESC': None, b'NOP': None, - b'GETINFO': lambda conn, _: keyring.sendline(conn, b'D ' + self.version), + b'GETINFO': self.handle_getinfo, b'AGENT_ID': lambda conn, _: keyring.sendline(conn, b'D TREZOR'), # "Fake" agent ID b'SIGKEY': lambda _, args: self.set_key(*args), b'SETKEY': lambda _, args: self.set_key(*args), @@ -102,6 +102,7 @@ class Handler(object): b'HAVEKEY': lambda _, args: self.have_key(*args), b'KEYINFO': _key_info, b'SCD': self.handle_scd, + b'GET_PASSPHRASE': self.handle_get_passphrase, } def reset(self): @@ -115,6 +116,32 @@ class Handler(object): self.options.append(opt) log.debug('options: %s', self.options) + def handle_get_passphrase(self, conn, _): + """Allow simple GPG symmetric encryption (using a passphrase).""" + p1 = self.client.device.ui.get_passphrase('Symmetric encryption') + p2 = self.client.device.ui.get_passphrase('Re-enter encryption') + if p1 == p2: + result = b'D ' + util.assuan_serialize(p1.encode('ascii')) + keyring.sendline(conn, result, confidential=True) + else: + log.warning('Passphrase does not match!') + + def handle_getinfo(self, conn, args): + """Handle some of the GETINFO messages.""" + result = None + if args[0] == b'version': + result = self.version + elif args[0] == b's2k_count': + # Use highest number of S2K iterations. + # https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Options.html + # https://tools.ietf.org/html/rfc4880#section-3.7.1.3 + result = '{}'.format(64 << 20).encode('ascii') + else: + log.warning('Unknown GETINFO command: %s', args) + + if result: + keyring.sendline(conn, b'D ' + result) + def handle_scd(self, conn, args): """No support for smart-card device protocol.""" reply = { diff --git a/libagent/gpg/keyring.py b/libagent/gpg/keyring.py index d191fb6..464395d 100644 --- a/libagent/gpg/keyring.py +++ b/libagent/gpg/keyring.py @@ -48,9 +48,9 @@ def communicate(sock, msg): return recvline(sock) -def sendline(sock, msg): +def sendline(sock, msg, confidential=False): """Send a binary message, followed by EOL.""" - log.debug('<- %r', msg) + log.debug('<- %r', ('' if confidential else msg)) sock.sendall(msg + b'\n') diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index 8c5c894..dc756da 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -23,9 +23,11 @@ log = logging.getLogger(__name__) UNIX_SOCKET_TIMEOUT = 0.1 -def ssh_args(label): +def ssh_args(conn): """Create SSH command for connecting specified server.""" - identity = device.interface.string_to_identity(label) + I, = conn.identities + identity = I.identity_dict + pubkey_tempfile, = conn.public_keys_as_files() args = [] if 'port' in identity: @@ -33,12 +35,15 @@ def ssh_args(label): if 'user' in identity: args += ['-l', identity['user']] + args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile.name)] + args += ['-o', 'IdentitiesOnly=true'] return args + [identity['host']] -def mosh_args(label): +def mosh_args(conn): """Create SSH command for connecting specified server.""" - identity = device.interface.string_to_identity(label) + I, = conn.identities + identity = I.identity_dict args = [] if 'port' in identity: @@ -60,7 +65,10 @@ def _to_unicode(s): def create_agent_parser(device_type): """Create an ArgumentParser for this tool.""" - p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config']) + epilog = ('See https://github.com/romanz/trezor-agent/blob/master/' + 'doc/README-SSH.md for usage examples.') + p = configargparse.ArgParser(default_config_files=['~/.ssh/agent.config'], + epilog=epilog) p.add_argument('-v', '--verbose', default=0, action='count') agent_package = device_type.package_name() @@ -89,6 +97,8 @@ def create_agent_parser(device_type): help='Path to PIN entry UI helper.') p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', help='Path to passphrase entry UI helper.') + p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), + help='Expire passphrase from cache after this duration.') g = p.add_mutually_exclusive_group() g.add_argument('-d', '--daemonize', default=False, action='store_true', @@ -191,6 +201,7 @@ class JustInTimeConnection(object): self.conn_factory = conn_factory self.identities = identities self.public_keys_cache = public_keys + self.public_keys_tempfiles = [] def public_keys(self): """Return a list of SSH public keys (in textual format).""" @@ -207,6 +218,17 @@ class JustInTimeConnection(object): pk['identity'] = identity return public_keys + def public_keys_as_files(self): + """Store public keys as temporary SSH identity files.""" + if not self.public_keys_tempfiles: + for pk in self.public_keys(): + f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w') + f.write(pk) + f.flush() + self.public_keys_tempfiles.append(f) + + return self.public_keys_tempfiles + def sign(self, blob, identity): """Sign a given blob using the specified identity on the device.""" conn = self.conn_factory() @@ -236,6 +258,7 @@ def main(device_type): util.setup_logging(verbosity=args.verbose, filename=args.log_file) public_keys = None + filename = None if args.identity.startswith('/'): filename = args.identity contents = open(filename, 'rb').read().decode('utf-8') @@ -250,14 +273,22 @@ def main(device_type): identity.identity_dict['proto'] = u'ssh' log.info('identity #%d: %s', index, identity.to_string()) - sock_path = _get_sock_path(args) + # override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey): + device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) + device_type.cached_passphrase_ack = util.ExpiringCache( + args.cache_expiry_seconds) + + conn = JustInTimeConnection( + conn_factory=lambda: client.Client(device_type()), + identities=identities, public_keys=public_keys) + sock_path = _get_sock_path(args) command = args.command context = _dummy_context() if args.connect: - command = ['ssh'] + ssh_args(args.identity) + args.command + command = ['ssh'] + ssh_args(conn) + args.command elif args.mosh: - command = ['mosh'] + mosh_args(args.identity) + args.command + command = ['mosh'] + mosh_args(conn) + args.command elif args.daemonize: out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path) sys.stdout.write(out) @@ -272,13 +303,6 @@ def main(device_type): command = os.environ['SHELL'] sys.stdin.close() - # override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey): - device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) - - conn = JustInTimeConnection( - conn_factory=lambda: client.Client(device_type()), - identities=identities, public_keys=public_keys) - if command or args.daemonize or args.foreground: with context: return run_server(conn=conn, command=command, sock_path=sock_path, diff --git a/libagent/tests/test_util.py b/libagent/tests/test_util.py index 2cef300..e85bc2f 100644 --- a/libagent/tests/test_util.py +++ b/libagent/tests/test_util.py @@ -121,3 +121,26 @@ def test_assuan_serialize(): assert util.assuan_serialize(b'') == b'' assert util.assuan_serialize(b'123\n456') == b'123%0A456' assert util.assuan_serialize(b'\r\n') == b'%0D%0A' + + +def test_cache(): + timer = mock.Mock(side_effect=range(7)) + c = util.ExpiringCache(seconds=2, timer=timer) # t=0 + assert c.get() is None # t=1 + obj = 'foo' + c.set(obj) # t=2 + assert c.get() is obj # t=3 + assert c.get() is obj # t=4 + assert c.get() is None # t=5 + assert c.get() is None # t=6 + + +def test_cache_inf(): + timer = mock.Mock(side_effect=range(6)) + c = util.ExpiringCache(seconds=float('inf'), timer=timer) + obj = 'foo' + c.set(obj) + assert c.get() is obj + assert c.get() is obj + assert c.get() is obj + assert c.get() is obj diff --git a/libagent/util.py b/libagent/util.py index c98891f..7df843b 100644 --- a/libagent/util.py +++ b/libagent/util.py @@ -5,6 +5,7 @@ import functools import io import logging import struct +import time log = logging.getLogger(__name__) @@ -255,3 +256,25 @@ def assuan_serialize(data): escaped = '%{:02X}'.format(ord(c)).encode('ascii') data = data.replace(c, escaped) return data + + +class ExpiringCache(object): + """Simple cache with a deadline.""" + + def __init__(self, seconds, timer=time.time): + """C-tor.""" + self.duration = seconds + self.timer = timer + self.value = None + self.set(None) + + def get(self): + """Returns existing value, or None if deadline has expired.""" + if self.timer() > self.deadline: + self.value = None + return self.value + + def set(self, value): + """Set new value and reset the deadline for expiration.""" + self.deadline = self.timer() + self.duration + self.value = value diff --git a/setup.py b/setup.py index 8520bb1..3d422a3 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup setup( name='libagent', - version='0.11.2', + version='0.11.3', description='Using hardware wallets as SSH/GPG agent', author='Roman Zeyde', author_email='roman.zeyde@gmail.com', @@ -34,10 +34,7 @@ setup( 'Intended Audience :: System Administrators', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Operating System :: POSIX', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3 :: Only', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Networking', 'Topic :: Communications', diff --git a/tox.ini b/tox.ini index 1165b3f..f25a59b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py3 +envlist = py3 [pycodestyle] max-line-length = 100 [pep257]