Generateurv2/backend/env/lib/python3.10/site-packages/autobahn/xbr/_config.py

589 lines
20 KiB
Python
Raw Normal View History

2022-06-24 17:14:37 +02:00
###############################################################################
#
# The MIT License (MIT)
#
# Copyright (c) Crossbar.io Technologies 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 os
import io
import sys
import uuid
import struct
import binascii
import configparser
from typing import Optional, List, Dict
import click
import nacl
import web3
import numpy as np
from time import time_ns
from eth_utils.conversions import hexstr_if_str, to_hex
from autobahn.websocket.util import parse_url
from autobahn.xbr._wallet import pkm_from_argon2_secret
_HAS_COLOR_TERM = False
try:
import colorama
# https://github.com/tartley/colorama/issues/48
term = None
if sys.platform == 'win32' and 'TERM' in os.environ:
term = os.environ.pop('TERM')
colorama.init()
_HAS_COLOR_TERM = True
if term:
os.environ['TERM'] = term
except ImportError:
pass
class Profile(object):
"""
User profile, stored as named section in ``${HOME}/.xbrnetwork/config.ini``:
.. code-block:: INI
[default]
# username used with this profile
username=joedoe
# user email used with the profile (e.g. for verification emails)
email=joe.doe@example.com
# XBR network node used as a directory server and gateway to XBR smart contracts
network_url=ws://localhost:8090/ws
# WAMP realm on network node, usually "xbrnetwork"
network_realm=xbrnetwork
# user private WAMP-cryptosign key (for client authentication)
cskey=0xb18bbe88ca0e189689e99f87b19addfb179d46aab3d59ec5d93a15286b949eb6
# user private Ethereum key (for signing transactions and e2e data encryption)
ethkey=0xfbada363e724d4db2faa2eeaa7d7aca37637b1076dd8cf6fefde13983abaa2ef
"""
def __init__(self,
path: Optional[str] = None,
name: Optional[str] = None,
member_adr: Optional[str] = None,
ethkey: Optional[bytes] = None,
cskey: Optional[bytes] = None,
username: Optional[str] = None,
email: Optional[str] = None,
network_url: Optional[str] = None,
network_realm: Optional[str] = None,
member_oid: Optional[uuid.UUID] = None,
vaction_oid: Optional[uuid.UUID] = None,
vaction_requested: Optional[np.datetime64] = None,
vaction_verified: Optional[np.datetime64] = None,
data_url: Optional[str] = None,
data_realm: Optional[str] = None,
infura_url: Optional[str] = None,
infura_network: Optional[str] = None,
infura_key: Optional[str] = None,
infura_secret: Optional[str] = None):
"""
:param path:
:param name:
:param member_adr:
:param ethkey:
:param cskey:
:param username:
:param email:
:param network_url:
:param network_realm:
:param member_oid:
:param vaction_oid:
:param vaction_requested:
:param vaction_verified:
:param data_url:
:param data_realm:
:param infura_url:
:param infura_network:
:param infura_key:
:param infura_secret:
"""
from txaio import make_logger
self.log = make_logger()
self.path = path
self.name = name
self.member_adr = member_adr
self.ethkey = ethkey
self.cskey = cskey
self.username = username
self.email = email
self.network_url = network_url
self.network_realm = network_realm
self.member_oid = member_oid
self.vaction_oid = vaction_oid
self.vaction_requested = vaction_requested
self.vaction_verified = vaction_verified
self.data_url = data_url
self.data_realm = data_realm
self.infura_url = infura_url
self.infura_network = infura_network
self.infura_key = infura_key
self.infura_secret = infura_secret
def marshal(self):
obj = {}
obj['member_adr'] = self.member_adr or ''
obj['ethkey'] = '0x{}'.format(binascii.b2a_hex(self.ethkey).decode()) if self.ethkey else ''
obj['cskey'] = '0x{}'.format(binascii.b2a_hex(self.cskey).decode()) if self.cskey else ''
obj['username'] = self.username or ''
obj['email'] = self.email or ''
obj['network_url'] = self.network_url or ''
obj['network_realm'] = self.network_realm or ''
obj['member_oid'] = str(self.member_oid) if self.member_oid else ''
obj['vaction_oid'] = str(self.vaction_oid) if self.vaction_oid else ''
obj['vaction_requested'] = str(self.vaction_requested) if self.vaction_requested else ''
obj['vaction_verified'] = str(self.vaction_verified) if self.vaction_verified else ''
obj['data_url'] = self.data_url or ''
obj['data_realm'] = self.data_realm or ''
obj['infura_url'] = self.infura_url or ''
obj['infura_network'] = self.infura_network or ''
obj['infura_key'] = self.infura_key or ''
obj['infura_secret'] = self.infura_secret or ''
return obj
@staticmethod
def parse(path, name, items):
member_adr = None
ethkey = None
cskey = None
username = None
email = None
network_url = None
network_realm = None
member_oid = None
vaction_oid = None
vaction_requested = None
vaction_verified = None
data_url = None
data_realm = None
infura_network = None
infura_key = None
infura_secret = None
infura_url = None
for k, v in items:
if k == 'network_url':
network_url = str(v)
elif k == 'network_realm':
network_realm = str(v)
elif k == 'vaction_oid':
if type(v) == str and v != '':
vaction_oid = uuid.UUID(v)
else:
vaction_oid = None
elif k == 'member_adr':
if type(v) == str and v != '':
member_adr = v
else:
member_adr = None
elif k == 'member_oid':
if type(v) == str and v != '':
member_oid = uuid.UUID(v)
else:
member_oid = None
elif k == 'vaction_requested':
if type(v) == int and v:
vaction_requested = np.datetime64(v, 'ns')
else:
vaction_requested = v
elif k == 'vaction_verified':
if type(v) == int:
vaction_verified = np.datetime64(v, 'ns')
else:
vaction_verified = v
elif k == 'data_url':
data_url = str(v)
elif k == 'data_realm':
data_realm = str(v)
elif k == 'ethkey':
ethkey = binascii.a2b_hex(v[2:])
elif k == 'cskey':
cskey = binascii.a2b_hex(v[2:])
elif k == 'username':
username = str(v)
elif k == 'email':
email = str(v)
elif k == 'infura_network':
infura_network = str(v)
elif k == 'infura_key':
infura_key = str(v)
elif k == 'infura_secret':
infura_secret = str(v)
elif k == 'infura_url':
infura_url = str(v)
elif k in ['path', 'name']:
pass
else:
# skip unknown attribute
print('unprocessed config attribute "{}"'.format(k))
return Profile(path, name,
member_adr, ethkey, cskey,
username, email,
network_url, network_realm,
member_oid,
vaction_oid, vaction_requested, vaction_verified,
data_url, data_realm,
infura_url, infura_network, infura_key, infura_secret)
class UserConfig(object):
"""
Local user configuration file. The data is either a plain text (unencrypted)
.ini file, or such a file encrypted with XSalsa20-Poly1305, and with a
binary file header of 48 octets.
"""
def __init__(self, config_path):
"""
:param config_path: The user configuration file path.
"""
from txaio import make_logger
self.log = make_logger()
self._config_path = os.path.abspath(config_path)
self._profiles = {}
@property
def config_path(self) -> List[str]:
"""
Return the path to the user configuration file exposed by this object.,
:return: Local filesystem path.
"""
return self._config_path
@property
def profiles(self) -> Dict[str, object]:
"""
Access to a map of user profiles in this user configuration.
:return: Map of user profiles.
"""
return self._profiles
def save(self, password: Optional[str] = None):
"""
Save this user configuration to the underlying configuration file. The user
configuration file can be encrypted using Argon2id when a ``password`` is given.
:param password: The optional Argon2id password.
:return: Number of octets written to the user configuration file.
"""
written = 0
config = configparser.ConfigParser()
for profile_name, profile in self._profiles.items():
if profile_name not in config.sections():
config.add_section(profile_name)
written += 1
pd = profile.marshal()
for option, value in pd.items():
config.set(profile_name, option, value)
with io.StringIO() as fp1:
config.write(fp1)
config_data = fp1.getvalue().encode('utf8')
if password:
# binary file format header (48 bytes):
#
# * 8 bytes: 0xdeadbeef 0x00000666 magic number (big endian)
# * 4 bytes: 0x00000001 encryption type 1 for "argon2id"
# * 4 bytes data length (big endian)
# * 8 bytes created timestamp ns (big endian)
# * 8 bytes unused (filled 0x00 currently)
# * 16 bytes salt
#
salt = os.urandom(16)
context = 'xbrnetwork-config'
priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
box = nacl.secret.SecretBox(priv_key)
config_data_ciphertext = box.encrypt(config_data)
dl = [
b'\xde\xad\xbe\xef',
b'\x00\x00\x06\x66',
b'\x00\x00\x00\x01',
struct.pack('>L', len(config_data_ciphertext)),
struct.pack('>Q', time_ns()),
b'\x00' * 8,
salt,
config_data_ciphertext,
]
data = b''.join(dl)
else:
data = config_data
with open(self._config_path, 'wb') as fp2:
fp2.write(data)
self.log.info('configuration with {sections} sections, {bytes_written} bytes written to {written_to}',
sections=written, bytes_written=len(data), written_to=self._config_path)
return len(data)
def load(self, cb_get_password=None) -> List[str]:
"""
Load this object from the underlying user configuration file. When the
file is encrypted, call back into ``cb_get_password`` to get the user password.
:param cb_get_password: Callback called when password is needed.
:return: List of profiles loaded.
"""
if not os.path.exists(self._config_path) or not os.path.isfile(self._config_path):
raise RuntimeError('config path "{}" cannot be loaded: so such file'.format(self._config_path))
with open(self._config_path, 'rb') as fp:
data = fp.read()
if len(data) >= 48 and data[:8] == b'\xde\xad\xbe\xef\x00\x00\x06\x66':
# binary format detected
header = data[:48]
body = data[48:]
algo = struct.unpack('>L', header[8:12])[0]
data_len = struct.unpack('>L', header[12:16])[0]
created = struct.unpack('>Q', header[16:24])[0]
# created_ts = np.datetime64(created, 'ns')
assert algo in [0, 1]
assert data_len == len(body)
assert created < time_ns()
salt = header[32:48]
context = 'xbrnetwork-config'
if cb_get_password:
password = cb_get_password()
else:
password = ''
priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
box = nacl.secret.SecretBox(priv_key)
body = box.decrypt(body)
else:
header = None
body = data
config = configparser.ConfigParser()
config.read_string(body.decode('utf8'))
profiles = {}
for profile_name in config.sections():
citems = config.items(profile_name)
profile = Profile.parse(self._config_path, profile_name, citems)
profiles[profile_name] = profile
self._profiles = profiles
loaded_profiles = sorted(self.profiles.keys())
return loaded_profiles
if 'CROSSBAR_FABRIC_URL' in os.environ:
_DEFAULT_CFC_URL = os.environ['CROSSBAR_FABRIC_URL']
else:
_DEFAULT_CFC_URL = u'wss://master.xbr.network/ws'
def style_error(text):
if _HAS_COLOR_TERM:
return click.style(text, fg='red', bold=True)
else:
return text
def style_ok(text):
if _HAS_COLOR_TERM:
return click.style(text, fg='green', bold=True)
else:
return text
class WampUrl(click.ParamType):
"""
WAMP transport URL validator.
"""
name = 'WAMP transport URL'
def __init__(self):
click.ParamType.__init__(self)
def convert(self, value, param, ctx):
try:
parse_url(value)
except Exception as e:
self.fail(style_error(str(e)))
else:
return value
def prompt_for_wamp_url(msg, default=None):
"""
Prompt user for WAMP transport URL (eg "wss://planet.xbr.network/ws").
"""
value = click.prompt(msg, type=WampUrl(), default=default)
return value
class EthereumAddress(click.ParamType):
"""
Ethereum address validator.
"""
name = 'Ethereum address'
def __init__(self):
click.ParamType.__init__(self)
def convert(self, value, param, ctx):
try:
value = web3.Web3.toChecksumAddress(value)
adr = binascii.a2b_hex(value[2:])
if len(value) != 20:
raise ValueError('Ethereum addres must be 20 bytes (160 bit), but was {} bytes'.format(len(adr)))
except Exception as e:
self.fail(style_error(str(e)))
else:
return value
def prompt_for_ethereum_address(msg):
"""
Prompt user for an Ethereum (public) address.
"""
value = click.prompt(msg, type=EthereumAddress())
return value
class PrivateKey(click.ParamType):
"""
Private key (32 bytes in HEX) validator.
"""
name = 'Private key'
def __init__(self, key_len):
click.ParamType.__init__(self)
self._key_len = key_len
def convert(self, value, param, ctx):
try:
value = hexstr_if_str(to_hex, value)
if value[:2] in ['0x', '\\x']:
key = binascii.a2b_hex(value[2:])
else:
key = binascii.a2b_hex(value)
if len(key) != self._key_len:
raise ValueError('key length must be {} bytes, but was {} bytes'.format(self._key_len, len(key)))
except Exception as e:
self.fail(style_error(str(e)))
else:
return value
def prompt_for_key(msg, key_len, default=None):
"""
Prompt user for a binary key of given length (in HEX).
"""
value = click.prompt(msg, type=PrivateKey(key_len), default=default)
return value
# default configuration stored in $HOME/.xbrnetwork/config.ini
_DEFAULT_CONFIG = """[default]
# username used with this profile
username={username}
# user email used with the profile (e.g. for verification emails)
email={email}
# XBR network node used as a directory server and gateway to XBR smart contracts
network_url={network_url}
# WAMP realm on network node, usually "xbrnetwork"
network_realm={network_realm}
# user private WAMP-cryptosign key (for client authentication)
cskey={cskey}
# user private Ethereum key (for signing transactions and e2e data encryption)
ethkey={ethkey}
"""
# # default XBR market URL to connect to
# market_url={market_url}
# market_realm={market_realm}
# # Infura blockchain gateway configuration
# infura_url={infura_url}
# infura_network={infura_network}
# infura_key={infura_key}
# infura_secret={infura_secret}
def load_or_create_profile(dotdir=None, profile=None, default_url=None, default_realm=None, default_email=None, default_username=None):
dotdir = dotdir or '~/.xbrnetwork'
profile = profile or 'default'
default_url = default_url or 'wss://planet.xbr.network/ws'
default_realm = default_realm or 'xbrnetwork'
config_dir = os.path.expanduser(dotdir)
if not os.path.isdir(config_dir):
os.mkdir(config_dir)
click.echo('created new local user directory {}'.format(style_ok(config_dir)))
config_path = os.path.join(config_dir, 'config.ini')
if not os.path.isfile(config_path):
click.echo('creating new user profile "{}"'.format(style_ok(profile)))
with open(config_path, 'w') as f:
network_url = prompt_for_wamp_url('enter the WAMP router URL of the network directory node', default=default_url)
network_realm = click.prompt('enter the WAMP realm to join on the network directory node', type=str, default=default_realm)
cskey = prompt_for_key('your private WAMP client key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
ethkey = prompt_for_key('your private Etherum key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
email = click.prompt('user email used for with profile', type=str, default=default_email)
username = click.prompt('user name used with this profile', type=str, default=default_username)
f.write(_DEFAULT_CONFIG.format(network_url=network_url, network_realm=network_realm, ethkey=ethkey,
cskey=cskey, email=email, username=username))
click.echo('created new local user configuration {}'.format(style_ok(config_path)))
config_obj = UserConfig(config_path)
config_obj.load()
profile_obj = config_obj.profiles.get(profile, None)
if not profile_obj:
raise click.ClickException('no such profile: "{}"'.format(profile))
return profile_obj