Split the package into a shared library and separate per-device packages
This commit is contained in:
3
libagent/device/__init__.py
Normal file
3
libagent/device/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Cryptographic hardware device management."""
|
||||
|
||||
from . import interface
|
||||
135
libagent/device/interface.py
Normal file
135
libagent/device/interface.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Device abstraction layer."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
|
||||
from .. import formats, util
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_identity_regexp = re.compile(''.join([
|
||||
'^'
|
||||
r'(?:(?P<proto>.*)://)?',
|
||||
r'(?:(?P<user>.*)@)?',
|
||||
r'(?P<host>.*?)',
|
||||
r'(?::(?P<port>\w*))?',
|
||||
r'(?P<path>/.*)?',
|
||||
'$'
|
||||
]))
|
||||
|
||||
|
||||
def string_to_identity(identity_str):
|
||||
"""Parse string into Identity dictionary."""
|
||||
m = _identity_regexp.match(identity_str)
|
||||
result = m.groupdict()
|
||||
log.debug('parsed identity: %s', result)
|
||||
return {k: v for k, v in result.items() if v}
|
||||
|
||||
|
||||
def identity_to_string(identity_dict):
|
||||
"""Dump Identity dictionary into its string representation."""
|
||||
result = []
|
||||
if identity_dict.get('proto'):
|
||||
result.append(identity_dict['proto'] + '://')
|
||||
if identity_dict.get('user'):
|
||||
result.append(identity_dict['user'] + '@')
|
||||
result.append(identity_dict['host'])
|
||||
if identity_dict.get('port'):
|
||||
result.append(':' + identity_dict['port'])
|
||||
if identity_dict.get('path'):
|
||||
result.append(identity_dict['path'])
|
||||
log.debug('identity parts: %s', result)
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Device-related error."""
|
||||
|
||||
|
||||
class NotFoundError(Error):
|
||||
"""Device could not be found."""
|
||||
|
||||
|
||||
class DeviceError(Error):
|
||||
"""Error during device operation."""
|
||||
|
||||
|
||||
class Identity(object):
|
||||
"""Represent SLIP-0013 identity, together with a elliptic curve choice."""
|
||||
|
||||
def __init__(self, identity_str, curve_name):
|
||||
"""Configure for specific identity and elliptic curve usage."""
|
||||
self.identity_dict = string_to_identity(identity_str)
|
||||
self.curve_name = curve_name
|
||||
|
||||
def items(self):
|
||||
"""Return a copy of identity_dict items."""
|
||||
return self.identity_dict.items()
|
||||
|
||||
def __str__(self):
|
||||
"""Return identity serialized to string."""
|
||||
return '<{}|{}>'.format(identity_to_string(self.identity_dict), self.curve_name)
|
||||
|
||||
def get_bip32_address(self, ecdh=False):
|
||||
"""Compute BIP32 derivation address according to SLIP-0013/0017."""
|
||||
index = struct.pack('<L', self.identity_dict.get('index', 0))
|
||||
addr = index + identity_to_string(self.identity_dict).encode('ascii')
|
||||
log.debug('bip32 address string: %r', addr)
|
||||
digest = hashlib.sha256(addr).digest()
|
||||
s = io.BytesIO(bytearray(digest))
|
||||
|
||||
hardened = 0x80000000
|
||||
addr_0 = 17 if bool(ecdh) else 13
|
||||
address_n = [addr_0] + list(util.recv(s, '<LLLL'))
|
||||
return [(hardened | value) for value in address_n]
|
||||
|
||||
def get_curve_name(self, ecdh=False):
|
||||
"""Return correct curve name for device operations."""
|
||||
if ecdh:
|
||||
return formats.get_ecdh_curve_name(self.curve_name)
|
||||
else:
|
||||
return self.curve_name
|
||||
|
||||
|
||||
class Device(object):
|
||||
"""Abstract cryptographic hardware device interface."""
|
||||
|
||||
def __init__(self):
|
||||
"""C-tor."""
|
||||
self.conn = None
|
||||
|
||||
def connect(self):
|
||||
"""Connect to device, otherwise raise NotFoundError."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow usage as context manager."""
|
||||
self.conn = self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close and mark as disconnected."""
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
log.exception('close failed: %s', e)
|
||||
self.conn = None
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get public key (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __str__(self):
|
||||
"""Human-readable representation."""
|
||||
return '{}'.format(self.__class__.__name__)
|
||||
37
libagent/device/keepkey.py
Normal file
37
libagent/device/keepkey.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""KeepKey-related code (see https://www.keepkey.com/)."""
|
||||
|
||||
from . import trezor
|
||||
from .. import formats
|
||||
|
||||
|
||||
def _verify_support(identity, ecdh):
|
||||
"""Make sure the device supports given configuration."""
|
||||
protocol = identity.identity_dict['proto']
|
||||
if protocol not in {'ssh'}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported protocol: {}'.format(protocol))
|
||||
if ecdh:
|
||||
raise NotImplementedError('No support for ECDH')
|
||||
if identity.curve_name not in {formats.CURVE_NIST256}:
|
||||
raise NotImplementedError(
|
||||
'Unsupported elliptic curve: {}'.format(identity.curve_name))
|
||||
|
||||
|
||||
class KeepKey(trezor.Trezor):
|
||||
"""Connection to KeepKey device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import keepkey_defs
|
||||
return keepkey_defs
|
||||
|
||||
required_version = '>=1.0.4'
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
_verify_support(identity, ecdh)
|
||||
return trezor.Trezor.pubkey(self, identity=identity, ecdh=ecdh)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""No support for ECDH in KeepKey firmware."""
|
||||
_verify_support(identity, ecdh=True)
|
||||
9
libagent/device/keepkey_defs.py
Normal file
9
libagent/device/keepkey_defs.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""KeepKey-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
|
||||
from keepkeylib.client import CallException as Error
|
||||
from keepkeylib.client import KeepKeyClient as Client
|
||||
from keepkeylib.messages_pb2 import PassphraseAck
|
||||
from keepkeylib.transport_hid import HidTransport as Transport
|
||||
from keepkeylib.types_pb2 import IdentityType
|
||||
111
libagent/device/ledger.py
Normal file
111
libagent/device/ledger.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Ledger-related code (see https://www.ledgerwallet.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from ledgerblue import comm # pylint: disable=import-error
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand_path(path):
|
||||
"""Convert BIP32 path into bytes."""
|
||||
return b''.join((struct.pack('>I', e) for e in path))
|
||||
|
||||
|
||||
def _convert_public_key(ecdsa_curve_name, result):
|
||||
"""Convert Ledger reply into PublicKey object."""
|
||||
if ecdsa_curve_name == 'nist256p1':
|
||||
if (result[64] & 1) != 0:
|
||||
result = bytearray([0x03]) + result[1:33]
|
||||
else:
|
||||
result = bytearray([0x02]) + result[1:33]
|
||||
else:
|
||||
result = result[1:]
|
||||
keyX = bytearray(result[0:32])
|
||||
keyY = bytearray(result[32:][::-1])
|
||||
if (keyX[31] & 1) != 0:
|
||||
keyY[31] |= 0x80
|
||||
result = b'\x00' + bytes(keyY)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
class LedgerNanoS(interface.Device):
|
||||
"""Connection to Ledger Nano S device."""
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
try:
|
||||
return comm.getDongle()
|
||||
except comm.CommException as e:
|
||||
raise interface.NotFoundError(
|
||||
'{} not connected: "{}"'.format(self, e))
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Get PublicKey object for specified BIP32 address and elliptic curve."""
|
||||
curve_name = identity.get_curve_name(ecdh)
|
||||
path = _expand_path(identity.get_bip32_address(ecdh))
|
||||
if curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800200' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(path) + 1, len(path) // 4])
|
||||
apdu += path
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))[1:]
|
||||
return _convert_public_key(curve_name, result)
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=False))
|
||||
if identity.identity_dict['proto'] == 'ssh':
|
||||
ins = '04'
|
||||
p1 = '00'
|
||||
else:
|
||||
ins = '08'
|
||||
p1 = '00'
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01'
|
||||
else:
|
||||
p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02'
|
||||
apdu = '80' + ins + p1 + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(blob) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += blob
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
if identity.curve_name == 'nist256p1':
|
||||
offset = 3
|
||||
length = result[offset]
|
||||
r = result[offset+1:offset+1+length]
|
||||
if r[0] == 0:
|
||||
r = r[1:]
|
||||
offset = offset + 1 + length + 1
|
||||
length = result[offset]
|
||||
s = result[offset+1:offset+1+length]
|
||||
if s[0] == 0:
|
||||
s = s[1:]
|
||||
offset = offset + 1 + length
|
||||
return bytes(r) + bytes(s)
|
||||
else:
|
||||
return bytes(result[:64])
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
path = _expand_path(identity.get_bip32_address(ecdh=True))
|
||||
if identity.curve_name == 'nist256p1':
|
||||
p2 = '01'
|
||||
else:
|
||||
p2 = '02'
|
||||
apdu = '800a00' + p2
|
||||
apdu = binascii.unhexlify(apdu)
|
||||
apdu += bytearray([len(pubkey) + len(path) + 1])
|
||||
apdu += bytearray([len(path) // 4]) + path
|
||||
apdu += pubkey
|
||||
result = bytearray(self.conn.exchange(bytes(apdu)))
|
||||
assert result[0] == 0x04
|
||||
return bytes(result)
|
||||
117
libagent/device/trezor.py
Normal file
117
libagent/device/trezor.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""TREZOR-related code (see http://bitcointrezor.com/)."""
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
|
||||
import semver
|
||||
|
||||
from . import interface
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Trezor(interface.Device):
|
||||
"""Connection to TREZOR device."""
|
||||
|
||||
@property
|
||||
def _defs(self):
|
||||
from . import trezor_defs
|
||||
# Allow using TREZOR bridge transport (instead of the HID default)
|
||||
trezor_defs.Transport = {
|
||||
'bridge': trezor_defs.BridgeTransport,
|
||||
}.get(os.environ.get('TREZOR_TRANSPORT'), trezor_defs.HidTransport)
|
||||
return trezor_defs
|
||||
|
||||
required_version = '>=1.4.0'
|
||||
passphrase = os.environ.get('TREZOR_PASSPHRASE', '')
|
||||
|
||||
def connect(self):
|
||||
"""Enumerate and connect to the first USB HID interface."""
|
||||
def passphrase_handler(_):
|
||||
log.debug('using %s passphrase for %s',
|
||||
'non-empty' if self.passphrase else 'empty', self)
|
||||
return self._defs.PassphraseAck(passphrase=self.passphrase)
|
||||
|
||||
for d in self._defs.Transport.enumerate():
|
||||
log.debug('endpoint: %s', d)
|
||||
transport = self._defs.Transport(d)
|
||||
connection = self._defs.Client(transport)
|
||||
connection.callback_PassphraseRequest = passphrase_handler
|
||||
f = connection.features
|
||||
log.debug('connected to %s %s', self, f.device_id)
|
||||
log.debug('label : %s', f.label)
|
||||
log.debug('vendor : %s', f.vendor)
|
||||
current_version = '{}.{}.{}'.format(f.major_version,
|
||||
f.minor_version,
|
||||
f.patch_version)
|
||||
log.debug('version : %s', current_version)
|
||||
log.debug('revision : %s', binascii.hexlify(f.revision))
|
||||
if not semver.match(current_version, self.required_version):
|
||||
fmt = ('Please upgrade your {} firmware to {} version'
|
||||
' (current: {})')
|
||||
raise ValueError(fmt.format(self, self.required_version,
|
||||
current_version))
|
||||
connection.ping(msg='', pin_protection=True) # unlock PIN
|
||||
return connection
|
||||
raise interface.NotFoundError('{} not connected'.format(self))
|
||||
|
||||
def close(self):
|
||||
"""Close connection."""
|
||||
self.conn.close()
|
||||
|
||||
def pubkey(self, identity, ecdh=False):
|
||||
"""Return public key."""
|
||||
curve_name = identity.get_curve_name(ecdh=ecdh)
|
||||
log.debug('"%s" getting public key (%s) from %s',
|
||||
identity, curve_name, self)
|
||||
addr = identity.get_bip32_address(ecdh=ecdh)
|
||||
result = self.conn.get_public_node(n=addr,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
return result.node.public_key
|
||||
|
||||
def _identity_proto(self, identity):
|
||||
result = self._defs.IdentityType()
|
||||
for name, value in identity.items():
|
||||
setattr(result, name, value)
|
||||
return result
|
||||
|
||||
def sign(self, identity, blob):
|
||||
"""Sign given blob and return the signature (as bytes)."""
|
||||
curve_name = identity.get_curve_name(ecdh=False)
|
||||
log.debug('"%s" signing %r (%s) on %s',
|
||||
identity, blob, curve_name, self)
|
||||
try:
|
||||
result = self.conn.sign_identity(
|
||||
identity=self._identity_proto(identity),
|
||||
challenge_hidden=blob,
|
||||
challenge_visual='',
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.signature) == 65
|
||||
assert result.signature[:1] == b'\x00'
|
||||
return result.signature[1:]
|
||||
except self._defs.Error as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
|
||||
def ecdh(self, identity, pubkey):
|
||||
"""Get shared session key using Elliptic Curve Diffie-Hellman."""
|
||||
curve_name = identity.get_curve_name(ecdh=True)
|
||||
log.debug('"%s" shared session key (%s) for %r from %s',
|
||||
identity, curve_name, pubkey, self)
|
||||
try:
|
||||
result = self.conn.get_ecdh_session_key(
|
||||
identity=self._identity_proto(identity),
|
||||
peer_public_key=pubkey,
|
||||
ecdsa_curve_name=curve_name)
|
||||
log.debug('result: %s', result)
|
||||
assert len(result.session_key) in {65, 33} # NIST256 or Curve25519
|
||||
assert result.session_key[:1] == b'\x04'
|
||||
return result.session_key
|
||||
except self._defs.Error as e:
|
||||
msg = '{} error: {}'.format(self, e)
|
||||
log.debug(msg, exc_info=True)
|
||||
raise interface.DeviceError(msg)
|
||||
10
libagent/device/trezor_defs.py
Normal file
10
libagent/device/trezor_defs.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""TREZOR-related definitions."""
|
||||
|
||||
# pylint: disable=unused-import,import-error
|
||||
|
||||
from trezorlib.client import CallException as Error
|
||||
from trezorlib.client import TrezorClient as Client
|
||||
from trezorlib.messages_pb2 import PassphraseAck
|
||||
from trezorlib.transport_bridge import BridgeTransport
|
||||
from trezorlib.transport_hid import HidTransport
|
||||
from trezorlib.types_pb2 import IdentityType
|
||||
Reference in New Issue
Block a user