568 lines
21 KiB
Python
568 lines
21 KiB
Python
###############################################################################
|
|
#
|
|
# The MIT License (MIT)
|
|
#
|
|
# Copyright (c) typedef int GmbH
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
#
|
|
###############################################################################
|
|
import binascii
|
|
import os
|
|
import configparser
|
|
from collections.abc import MutableMapping
|
|
from typing import Optional, Union, Dict, Any, List, Iterator
|
|
from threading import Lock
|
|
|
|
import txaio
|
|
import nacl
|
|
|
|
from eth_account.account import Account
|
|
from eth_account.signers.local import LocalAccount
|
|
|
|
from py_eth_sig_utils.eip712 import encode_typed_data
|
|
from py_eth_sig_utils.utils import ecsign, ecrecover_to_pub, checksum_encode, sha3
|
|
from py_eth_sig_utils.signing import v_r_s_to_signature, signature_to_v_r_s
|
|
|
|
from autobahn.wamp.interfaces import ISecurityModule, IEthereumKey
|
|
from autobahn.xbr._mnemonic import mnemonic_to_private_key
|
|
from autobahn.util import parse_keyfile
|
|
from autobahn.wamp.cryptosign import CryptosignKey
|
|
|
|
__all__ = ('EthereumKey', 'SecurityModuleMemory', )
|
|
|
|
|
|
class EthereumKey(object):
|
|
"""
|
|
Base class to implement :class:`autobahn.wamp.interfaces.IEthereumKey`.
|
|
"""
|
|
|
|
def __init__(self, key_or_address: Union[LocalAccount, str, bytes], can_sign: bool,
|
|
security_module: Optional[ISecurityModule] = None,
|
|
key_no: Optional[int] = None) -> None:
|
|
if can_sign:
|
|
# https://eth-account.readthedocs.io/en/latest/eth_account.html#eth_account.account.Account
|
|
assert type(key_or_address) == LocalAccount
|
|
self._key = key_or_address
|
|
self._address = key_or_address.address
|
|
else:
|
|
assert type(key_or_address) in (str, bytes)
|
|
self._key = None
|
|
self._address = key_or_address
|
|
self._can_sign = can_sign
|
|
self._security_module = security_module
|
|
self._key_no = key_no
|
|
|
|
@property
|
|
def security_module(self) -> Optional['ISecurityModule']:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.security_module`.
|
|
"""
|
|
return self._security_module
|
|
|
|
@property
|
|
def key_no(self) -> Optional[int]:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.key_no`.
|
|
"""
|
|
return self._key_no
|
|
|
|
@property
|
|
def key_type(self) -> str:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.key_type`.
|
|
"""
|
|
return 'ethereum'
|
|
|
|
def public_key(self, binary: bool = False) -> Union[str, bytes]:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.public_key`.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
@property
|
|
def can_sign(self) -> bool:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.can_sign`.
|
|
"""
|
|
return self._can_sign
|
|
|
|
def address(self, binary: bool = False) -> Union[str, bytes]:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IEthereumKey.address`.
|
|
"""
|
|
if binary:
|
|
return binascii.a2b_hex(self._address[2:])
|
|
else:
|
|
return self._address
|
|
|
|
def sign(self, data: bytes) -> bytes:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.sign`.
|
|
"""
|
|
# FIXME: implement signing of raw data
|
|
raise NotImplementedError()
|
|
|
|
def recover(self, data: bytes, signature: bytes) -> bytes:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IKey.recover`.
|
|
"""
|
|
# FIXME: implement signing address recovery from signature of raw data
|
|
raise NotImplementedError()
|
|
|
|
def sign_typed_data(self, data: Dict[str, Any], binary=True) -> bytes:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IEthereumKey.sign_typed_data`.
|
|
"""
|
|
if self._security_module:
|
|
assert self._security_module.is_open and not self._security_module.is_locked, 'security module must be open and unlocked'
|
|
try:
|
|
# encode typed data dict and return message hash
|
|
msg_hash = encode_typed_data(data)
|
|
|
|
# ECDSA signatures in Ethereum consist of three parameters: v, r and s.
|
|
# The signature is always 65-bytes in length.
|
|
# r = first 32 bytes of signature
|
|
# s = second 32 bytes of signature
|
|
# v = final 1 byte of signature
|
|
signature_vrs = ecsign(msg_hash, self._key.key)
|
|
|
|
# concatenate signature components into byte string
|
|
signature = v_r_s_to_signature(*signature_vrs)
|
|
except Exception as e:
|
|
return txaio.create_future_error(e)
|
|
else:
|
|
if binary:
|
|
return txaio.create_future_success(signature)
|
|
else:
|
|
return txaio.create_future_success(binascii.b2a_hex(signature).decode())
|
|
|
|
def verify_typed_data(self, data: Dict[str, Any], signature: bytes) -> bool:
|
|
"""
|
|
Implements :meth:`autobahn.wamp.interfaces.IEthereumKey.verify_typed_data`.
|
|
"""
|
|
if self._security_module:
|
|
assert self._security_module.is_open and not self._security_module.is_locked, 'security module must be open and unlocked'
|
|
try:
|
|
msg_hash = encode_typed_data(data)
|
|
signature_vrs = signature_to_v_r_s(signature)
|
|
public_key = ecrecover_to_pub(msg_hash, *signature_vrs)
|
|
address_bytes = sha3(public_key)[-20:]
|
|
address = checksum_encode(address_bytes)
|
|
except Exception as e:
|
|
return txaio.create_future_error(e)
|
|
else:
|
|
return txaio.create_future_success(address == self._address)
|
|
|
|
@classmethod
|
|
def from_address(cls, address: Union[str, bytes]) -> 'EthereumKey':
|
|
"""
|
|
Create a public key from an address, which can be used to verify signatures.
|
|
|
|
:param address: The Ethereum address (20 octets).
|
|
:return: New instance of :class:`EthereumKey`
|
|
"""
|
|
return EthereumKey(key_or_address=address, can_sign=False)
|
|
|
|
@classmethod
|
|
def from_bytes(cls, key: bytes) -> 'EthereumKey':
|
|
"""
|
|
Create a private key from seed bytes, which can be used to sign and create signatures.
|
|
|
|
:param key: The Ethereum private key seed (32 octets).
|
|
:return: New instance of :class:`EthereumKey`
|
|
"""
|
|
if type(key) != bytes:
|
|
raise ValueError("invalid seed type {} (expected binary)".format(type(key)))
|
|
|
|
if len(key) != 32:
|
|
raise ValueError("invalid seed length {} (expected 32)".format(len(key)))
|
|
|
|
account: LocalAccount = Account.from_key(key)
|
|
return EthereumKey(key_or_address=account, can_sign=True)
|
|
|
|
@classmethod
|
|
def from_seedphrase(cls, seedphrase: str, index: int = 0) -> 'EthereumKey':
|
|
"""
|
|
Create a private key from the given BIP-39 mnemonic seed phrase and index,
|
|
which can be used to sign and create signatures.
|
|
|
|
:param seedphrase: The BIP-39 seedphrase ("Mnemonic") from which to derive the account.
|
|
:param index: The account index in account hierarchy defined by the seedphrase.
|
|
:return: New instance of :class:`EthereumKey`
|
|
"""
|
|
# Base HD Path: m/44'/60'/0'/0/{account_index}
|
|
derivation_path = "m/44'/60'/0'/0/{}".format(index)
|
|
|
|
key = mnemonic_to_private_key(seedphrase, str_derivation_path=derivation_path)
|
|
assert type(key) == bytes
|
|
assert len(key) == 32
|
|
|
|
account: LocalAccount = Account.from_key(key)
|
|
return EthereumKey(key_or_address=account, can_sign=True)
|
|
|
|
@classmethod
|
|
def from_keyfile(cls, keyfile: str) -> 'EthereumKey':
|
|
"""
|
|
Create a public or private key from reading the given public or private key file.
|
|
|
|
Here is an example key file that includes an Ethereum private key ``private-key-eth``, which
|
|
is loaded in this function, and other fields, which are ignored by this function:
|
|
|
|
.. code-block::
|
|
|
|
This is a comment (all lines until the first empty line are comments indeed).
|
|
|
|
creator: oberstet@intel-nuci7
|
|
created-at: 2022-07-05T12:29:48.832Z
|
|
user-id: oberstet@intel-nuci7
|
|
public-key-ed25519: 7326d9dc0307681cc6940fde0e60eb31a6e4d642a81e55c434462ce31f95deed
|
|
public-adr-eth: 0x10848feBdf7f200Ba989CDf7E3eEB3EC03ae7768
|
|
private-key-ed25519: f750f42b0430e28a2e272c3cedcae4dcc4a1cf33bc345c35099d3322626ab666
|
|
private-key-eth: 4d787714dcb0ae52e1c5d2144648c255d660b9a55eac9deeb80d9f506f501025
|
|
|
|
:param keyfile: Path (relative or absolute) to a public or private keys file.
|
|
:return: New instance of :class:`EthereumKey`
|
|
"""
|
|
if not os.path.exists(keyfile) or not os.path.isfile(keyfile):
|
|
raise RuntimeError('keyfile "{}" is not a file'.format(keyfile))
|
|
|
|
# now load the private or public key file - this returns a dict which should
|
|
# include (for a private key):
|
|
#
|
|
# private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b
|
|
#
|
|
# or (for a public key only):
|
|
#
|
|
# public-adr-eth: 0x10848feBdf7f200Ba989CDf7E3eEB3EC03ae7768
|
|
#
|
|
data = parse_keyfile(keyfile)
|
|
|
|
privkey_eth_hex = data.get('private-key-eth', None)
|
|
if privkey_eth_hex is None:
|
|
pub_adr_eth = data.get('public-adr-eth', None)
|
|
if pub_adr_eth is None:
|
|
raise RuntimeError('neither "private-key-eth" nor "public-adr-eth" found in keyfile {}'.format(keyfile))
|
|
else:
|
|
return EthereumKey.from_address(pub_adr_eth)
|
|
else:
|
|
return EthereumKey.from_bytes(binascii.a2b_hex(privkey_eth_hex))
|
|
|
|
|
|
IEthereumKey.register(EthereumKey)
|
|
|
|
|
|
class SecurityModuleMemory(MutableMapping):
|
|
"""
|
|
A transient, memory-based implementation of :class:`ISecurityModule`.
|
|
"""
|
|
|
|
def __init__(self, keys: Optional[List[Union[CryptosignKey, EthereumKey]]] = None):
|
|
self._mutex = Lock()
|
|
self._is_open = False
|
|
self._is_locked = True
|
|
self._keys: Dict[int, Union[CryptosignKey, EthereumKey]] = {}
|
|
self._counters: Dict[int, int] = {}
|
|
if keys:
|
|
for i, key in enumerate(keys):
|
|
self._keys[i] = key
|
|
|
|
def __len__(self) -> int:
|
|
"""
|
|
Implements :meth:`ISecurityModule.__len__`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
return len(self._keys)
|
|
|
|
def __contains__(self, key_no: int) -> bool:
|
|
assert self._is_open, 'security module not open'
|
|
|
|
return key_no in self._keys
|
|
|
|
def __iter__(self) -> Iterator[int]:
|
|
"""
|
|
Implements :meth:`ISecurityModule.__iter__`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
yield from self._keys
|
|
|
|
def __getitem__(self, key_no: int) -> Union[CryptosignKey, EthereumKey]:
|
|
"""
|
|
Implements :meth:`ISecurityModule.__getitem__`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
if key_no in self._keys:
|
|
return self._keys[key_no]
|
|
else:
|
|
raise IndexError('key_no {} not found'.format(key_no))
|
|
|
|
def __setitem__(self, key_no: int, key: Union[CryptosignKey, EthereumKey]) -> None:
|
|
assert self._is_open, 'security module not open'
|
|
|
|
assert key_no >= 0
|
|
if key_no in self._keys:
|
|
# FIXME
|
|
pass
|
|
self._keys[key_no] = key
|
|
|
|
def __delitem__(self, key_no: int) -> None:
|
|
assert self._is_open, 'security module not open'
|
|
|
|
if key_no in self._keys:
|
|
del self._keys[key_no]
|
|
else:
|
|
raise IndexError()
|
|
|
|
def open(self):
|
|
"""
|
|
Implements :meth:`ISecurityModule.open`
|
|
"""
|
|
assert not self._is_open, 'security module already open'
|
|
|
|
self._is_open = True
|
|
return txaio.create_future_success(None)
|
|
|
|
def close(self):
|
|
"""
|
|
Implements :meth:`ISecurityModule.close`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
self._is_open = False
|
|
self._is_locked = True
|
|
return txaio.create_future_success(None)
|
|
|
|
@property
|
|
def is_open(self) -> bool:
|
|
"""
|
|
Implements :meth:`ISecurityModule.is_open`
|
|
"""
|
|
return self._is_open
|
|
|
|
@property
|
|
def can_lock(self) -> bool:
|
|
"""
|
|
Implements :meth:`ISecurityModule.can_lock`
|
|
"""
|
|
return True
|
|
|
|
@property
|
|
def is_locked(self) -> bool:
|
|
"""
|
|
Implements :meth:`ISecurityModule.is_locked`
|
|
"""
|
|
return self._is_locked
|
|
|
|
def lock(self):
|
|
"""
|
|
Implements :meth:`ISecurityModule.lock`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
assert not self._is_locked
|
|
|
|
self._is_locked = True
|
|
return txaio.create_future_success(None)
|
|
|
|
def unlock(self):
|
|
"""
|
|
Implements :meth:`ISecurityModule.unlock`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
assert self._is_locked
|
|
|
|
self._is_locked = False
|
|
return txaio.create_future_success(None)
|
|
|
|
def create_key(self, key_type: str) -> int:
|
|
assert self._is_open, 'security module not open'
|
|
|
|
key_no = len(self._keys)
|
|
if key_type == 'cryptosign':
|
|
key = CryptosignKey(key=nacl.signing.SigningKey(os.urandom(32)),
|
|
can_sign=True,
|
|
security_module=self,
|
|
key_no=key_no)
|
|
elif key_type == 'ethereum':
|
|
key = EthereumKey(key_or_address=Account.from_key(os.urandom(32)),
|
|
can_sign=True,
|
|
security_module=self,
|
|
key_no=key_no)
|
|
else:
|
|
raise ValueError('invalid key_type "{}"'.format(key_type))
|
|
self._keys[key_no] = key
|
|
return txaio.create_future_success(key_no)
|
|
|
|
def delete_key(self, key_no: int):
|
|
assert self._is_open, 'security module not open'
|
|
|
|
if key_no in self._keys:
|
|
del self._keys[key_no]
|
|
return txaio.create_future_success(key_no)
|
|
else:
|
|
return txaio.create_future_success(None)
|
|
|
|
def get_random(self, octets: int) -> bytes:
|
|
"""
|
|
Implements :meth:`ISecurityModule.get_random`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
data = os.urandom(octets)
|
|
return txaio.create_future_success(data)
|
|
|
|
def get_counter(self, counter_no: int) -> int:
|
|
"""
|
|
Implements :meth:`ISecurityModule.get_counter`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
self._mutex.acquire()
|
|
res = self._counters.get(counter_no, 0)
|
|
self._mutex.release()
|
|
return txaio.create_future_success(res)
|
|
|
|
def increment_counter(self, counter_no: int) -> int:
|
|
"""
|
|
Implements :meth:`ISecurityModule.increment_counter`
|
|
"""
|
|
assert self._is_open, 'security module not open'
|
|
|
|
self._mutex.acquire()
|
|
if counter_no not in self._counters:
|
|
self._counters[counter_no] = 0
|
|
self._counters[counter_no] += 1
|
|
res = self._counters[counter_no]
|
|
self._mutex.release()
|
|
return txaio.create_future_success(res)
|
|
|
|
@classmethod
|
|
def from_seedphrase(cls, seedphrase: str, num_eth_keys: int = 1,
|
|
num_cs_keys: int = 1) -> 'SecurityModuleMemory':
|
|
"""
|
|
Create a new memory-backed security module with
|
|
|
|
1. ``num_eth_keys`` keys of type :class:`EthereumKey`, followed by
|
|
2. ``num_cs_keys`` keys of type :class:`CryptosignKey`
|
|
|
|
computed from a (common) BIP44 seedphrase.
|
|
|
|
:param seedphrase: BIP44 seedphrase to use.
|
|
:param num_eth_keys: Number of Ethereum keys to derive.
|
|
:param num_cs_keys: Number of Cryptosign keys to derive.
|
|
:return: New memory-backed security module instance.
|
|
"""
|
|
keys: List[Union[EthereumKey, CryptosignKey]] = []
|
|
|
|
# first, add num_eth_keys EthereumKey(s), numbering starting at 0
|
|
for i in range(num_eth_keys):
|
|
key = EthereumKey.from_seedphrase(seedphrase, i)
|
|
keys.append(key)
|
|
|
|
# second, add num_cs_keys CryptosignKey(s), numbering starting at num_eth_keys (!)
|
|
for i in range(num_cs_keys):
|
|
key = CryptosignKey.from_seedphrase(seedphrase, i + num_eth_keys)
|
|
keys.append(key)
|
|
|
|
# initialize security module from collected keys
|
|
sm = SecurityModuleMemory(keys=keys)
|
|
return sm
|
|
|
|
@classmethod
|
|
def from_config(cls, config: str, profile: str = 'default') -> 'SecurityModuleMemory':
|
|
"""
|
|
Create a new memory-backed security module with keys referred from a profile in
|
|
the given configuration file.
|
|
|
|
:param config: Path (relative or absolute) to an INI configuration file.
|
|
:param profile: Name of the profile within the given INI configuration file.
|
|
:return: New memory-backed security module instance.
|
|
"""
|
|
keys: List[Union[EthereumKey, CryptosignKey]] = []
|
|
|
|
cfg = configparser.ConfigParser()
|
|
cfg.read(config)
|
|
|
|
if not cfg.has_section(profile):
|
|
raise RuntimeError('profile "{}" not found in configuration file "{}"'.format(profile, config))
|
|
|
|
if not cfg.has_option(profile, 'privkey'):
|
|
raise RuntimeError('missing option "privkey" in profile "{}" of configuration file "{}"'.format(profile, config))
|
|
|
|
privkey = os.path.join(os.path.dirname(config), cfg.get(profile, 'privkey'))
|
|
if not os.path.exists(privkey) or not os.path.isfile(privkey):
|
|
raise RuntimeError('privkey "{}" is not a file in profile "{}" of configuration file "{}"'.format(privkey, profile, config))
|
|
|
|
# now load the private key file - this returns a dict which should include:
|
|
# private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b
|
|
# private-key-ed25519: 20e8c05d0ede9506462bb049c4843032b18e8e75b314583d0c8d8a4942f9be40
|
|
data = parse_keyfile(privkey)
|
|
|
|
# first, add Ethereum key
|
|
privkey_eth_hex = data.get('private-key-eth', None)
|
|
keys.append(EthereumKey.from_bytes(binascii.a2b_hex(privkey_eth_hex)))
|
|
|
|
# second, add Cryptosign key
|
|
privkey_ed25519_hex = data.get('private-key-ed25519', None)
|
|
keys.append(CryptosignKey.from_bytes(binascii.a2b_hex(privkey_ed25519_hex)))
|
|
|
|
# initialize security module from collected keys
|
|
sm = SecurityModuleMemory(keys=keys)
|
|
return sm
|
|
|
|
@classmethod
|
|
def from_keyfile(cls, keyfile: str) -> 'SecurityModuleMemory':
|
|
"""
|
|
Create a new memory-backed security module with keys referred from a profile in
|
|
the given configuration file.
|
|
|
|
:param keyfile: Path (relative or absolute) to a private keys file.
|
|
:return: New memory-backed security module instance.
|
|
"""
|
|
keys: List[Union[EthereumKey, CryptosignKey]] = []
|
|
|
|
if not os.path.exists(keyfile) or not os.path.isfile(keyfile):
|
|
raise RuntimeError('keyfile "{}" is not a file'.format(keyfile))
|
|
|
|
# now load the private key file - this returns a dict which should include:
|
|
# private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b
|
|
# private-key-ed25519: 20e8c05d0ede9506462bb049c4843032b18e8e75b314583d0c8d8a4942f9be40
|
|
data = parse_keyfile(keyfile)
|
|
|
|
# first, add Ethereum key
|
|
privkey_eth_hex = data.get('private-key-eth', None)
|
|
if privkey_eth_hex is None:
|
|
raise RuntimeError('"private-key-eth" not found in keyfile {}'.format(keyfile))
|
|
keys.append(EthereumKey.from_bytes(binascii.a2b_hex(privkey_eth_hex)))
|
|
|
|
# second, add Cryptosign key
|
|
privkey_ed25519_hex = data.get('private-key-ed25519', None)
|
|
if privkey_ed25519_hex is None:
|
|
raise RuntimeError('"private-key-ed25519" not found in keyfile {}'.format(keyfile))
|
|
keys.append(CryptosignKey.from_bytes(binascii.a2b_hex(privkey_ed25519_hex)))
|
|
|
|
# initialize security module from collected keys
|
|
sm = SecurityModuleMemory(keys=keys)
|
|
return sm
|
|
|
|
|
|
ISecurityModule.register(SecurityModuleMemory)
|