589 lines
20 KiB
Python
589 lines
20 KiB
Python
|
###############################################################################
|
||
|
#
|
||
|
# 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
|