Merge branch 'master' into neopg-wip

master
Roman Zeyde 6 years ago
commit 4968ca7ff3
No known key found for this signature in database
GPG Key ID: 87CAE5FA46917CBB

@ -1,7 +1,7 @@
[bumpversion]
commit = True
tag = True
current_version = 0.11.2
current_version = 0.11.3
[bumpversion:file:setup.py]

@ -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

@ -1,8 +1,6 @@
sudo: false
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"

@ -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=[

@ -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

@ -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:
```

@ -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.

@ -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.

@ -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

@ -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()

@ -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,

@ -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)

@ -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 = {

@ -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', ('<snip>' if confidential else msg))
sock.sendall(msg + b'\n')

@ -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,

@ -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

@ -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

@ -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',

@ -1,5 +1,5 @@
[tox]
envlist = py27,py3
envlist = py3
[pycodestyle]
max-line-length = 100
[pep257]

Loading…
Cancel
Save