Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

_config.py 20KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. ###############################################################################
  2. #
  3. # The MIT License (MIT)
  4. #
  5. # Copyright (c) typedef int GmbH
  6. #
  7. # Permission is hereby granted, free of charge, to any person obtaining a copy
  8. # of this software and associated documentation files (the "Software"), to deal
  9. # in the Software without restriction, including without limitation the rights
  10. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. # copies of the Software, and to permit persons to whom the Software is
  12. # furnished to do so, subject to the following conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be included in
  15. # all copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  23. # THE SOFTWARE.
  24. #
  25. ###############################################################################
  26. import os
  27. import io
  28. import sys
  29. import uuid
  30. import struct
  31. import binascii
  32. import configparser
  33. from typing import Optional, List, Dict
  34. import click
  35. import nacl
  36. import web3
  37. import numpy as np
  38. from time import time_ns
  39. from eth_utils.conversions import hexstr_if_str, to_hex
  40. from autobahn.websocket.util import parse_url
  41. from autobahn.xbr._wallet import pkm_from_argon2_secret
  42. _HAS_COLOR_TERM = False
  43. try:
  44. import colorama
  45. # https://github.com/tartley/colorama/issues/48
  46. term = None
  47. if sys.platform == 'win32' and 'TERM' in os.environ:
  48. term = os.environ.pop('TERM')
  49. colorama.init()
  50. _HAS_COLOR_TERM = True
  51. if term:
  52. os.environ['TERM'] = term
  53. except ImportError:
  54. pass
  55. class Profile(object):
  56. """
  57. User profile, stored as named section in ``${HOME}/.xbrnetwork/config.ini``:
  58. .. code-block:: INI
  59. [default]
  60. # username used with this profile
  61. username=joedoe
  62. # user email used with the profile (e.g. for verification emails)
  63. email=joe.doe@example.com
  64. # XBR network node used as a directory server and gateway to XBR smart contracts
  65. network_url=ws://localhost:8090/ws
  66. # WAMP realm on network node, usually "xbrnetwork"
  67. network_realm=xbrnetwork
  68. # user private WAMP-cryptosign key (for client authentication)
  69. cskey=0xb18bbe88ca0e189689e99f87b19addfb179d46aab3d59ec5d93a15286b949eb6
  70. # user private Ethereum key (for signing transactions and e2e data encryption)
  71. ethkey=0xfbada363e724d4db2faa2eeaa7d7aca37637b1076dd8cf6fefde13983abaa2ef
  72. """
  73. def __init__(self,
  74. path: Optional[str] = None,
  75. name: Optional[str] = None,
  76. member_adr: Optional[str] = None,
  77. ethkey: Optional[bytes] = None,
  78. cskey: Optional[bytes] = None,
  79. username: Optional[str] = None,
  80. email: Optional[str] = None,
  81. network_url: Optional[str] = None,
  82. network_realm: Optional[str] = None,
  83. member_oid: Optional[uuid.UUID] = None,
  84. vaction_oid: Optional[uuid.UUID] = None,
  85. vaction_requested: Optional[np.datetime64] = None,
  86. vaction_verified: Optional[np.datetime64] = None,
  87. data_url: Optional[str] = None,
  88. data_realm: Optional[str] = None,
  89. infura_url: Optional[str] = None,
  90. infura_network: Optional[str] = None,
  91. infura_key: Optional[str] = None,
  92. infura_secret: Optional[str] = None):
  93. """
  94. :param path:
  95. :param name:
  96. :param member_adr:
  97. :param ethkey:
  98. :param cskey:
  99. :param username:
  100. :param email:
  101. :param network_url:
  102. :param network_realm:
  103. :param member_oid:
  104. :param vaction_oid:
  105. :param vaction_requested:
  106. :param vaction_verified:
  107. :param data_url:
  108. :param data_realm:
  109. :param infura_url:
  110. :param infura_network:
  111. :param infura_key:
  112. :param infura_secret:
  113. """
  114. from txaio import make_logger
  115. self.log = make_logger()
  116. self.path = path
  117. self.name = name
  118. self.member_adr = member_adr
  119. self.ethkey = ethkey
  120. self.cskey = cskey
  121. self.username = username
  122. self.email = email
  123. self.network_url = network_url
  124. self.network_realm = network_realm
  125. self.member_oid = member_oid
  126. self.vaction_oid = vaction_oid
  127. self.vaction_requested = vaction_requested
  128. self.vaction_verified = vaction_verified
  129. self.data_url = data_url
  130. self.data_realm = data_realm
  131. self.infura_url = infura_url
  132. self.infura_network = infura_network
  133. self.infura_key = infura_key
  134. self.infura_secret = infura_secret
  135. def marshal(self):
  136. obj = {}
  137. obj['member_adr'] = self.member_adr or ''
  138. obj['ethkey'] = '0x{}'.format(binascii.b2a_hex(self.ethkey).decode()) if self.ethkey else ''
  139. obj['cskey'] = '0x{}'.format(binascii.b2a_hex(self.cskey).decode()) if self.cskey else ''
  140. obj['username'] = self.username or ''
  141. obj['email'] = self.email or ''
  142. obj['network_url'] = self.network_url or ''
  143. obj['network_realm'] = self.network_realm or ''
  144. obj['member_oid'] = str(self.member_oid) if self.member_oid else ''
  145. obj['vaction_oid'] = str(self.vaction_oid) if self.vaction_oid else ''
  146. obj['vaction_requested'] = str(self.vaction_requested) if self.vaction_requested else ''
  147. obj['vaction_verified'] = str(self.vaction_verified) if self.vaction_verified else ''
  148. obj['data_url'] = self.data_url or ''
  149. obj['data_realm'] = self.data_realm or ''
  150. obj['infura_url'] = self.infura_url or ''
  151. obj['infura_network'] = self.infura_network or ''
  152. obj['infura_key'] = self.infura_key or ''
  153. obj['infura_secret'] = self.infura_secret or ''
  154. return obj
  155. @staticmethod
  156. def parse(path, name, items):
  157. member_adr = None
  158. ethkey = None
  159. cskey = None
  160. username = None
  161. email = None
  162. network_url = None
  163. network_realm = None
  164. member_oid = None
  165. vaction_oid = None
  166. vaction_requested = None
  167. vaction_verified = None
  168. data_url = None
  169. data_realm = None
  170. infura_network = None
  171. infura_key = None
  172. infura_secret = None
  173. infura_url = None
  174. for k, v in items:
  175. if k == 'network_url':
  176. network_url = str(v)
  177. elif k == 'network_realm':
  178. network_realm = str(v)
  179. elif k == 'vaction_oid':
  180. if type(v) == str and v != '':
  181. vaction_oid = uuid.UUID(v)
  182. else:
  183. vaction_oid = None
  184. elif k == 'member_adr':
  185. if type(v) == str and v != '':
  186. member_adr = v
  187. else:
  188. member_adr = None
  189. elif k == 'member_oid':
  190. if type(v) == str and v != '':
  191. member_oid = uuid.UUID(v)
  192. else:
  193. member_oid = None
  194. elif k == 'vaction_requested':
  195. if type(v) == int and v:
  196. vaction_requested = np.datetime64(v, 'ns')
  197. else:
  198. vaction_requested = v
  199. elif k == 'vaction_verified':
  200. if type(v) == int:
  201. vaction_verified = np.datetime64(v, 'ns')
  202. else:
  203. vaction_verified = v
  204. elif k == 'data_url':
  205. data_url = str(v)
  206. elif k == 'data_realm':
  207. data_realm = str(v)
  208. elif k == 'ethkey':
  209. ethkey = binascii.a2b_hex(v[2:])
  210. elif k == 'cskey':
  211. cskey = binascii.a2b_hex(v[2:])
  212. elif k == 'username':
  213. username = str(v)
  214. elif k == 'email':
  215. email = str(v)
  216. elif k == 'infura_network':
  217. infura_network = str(v)
  218. elif k == 'infura_key':
  219. infura_key = str(v)
  220. elif k == 'infura_secret':
  221. infura_secret = str(v)
  222. elif k == 'infura_url':
  223. infura_url = str(v)
  224. elif k in ['path', 'name']:
  225. pass
  226. else:
  227. # skip unknown attribute
  228. print('unprocessed config attribute "{}"'.format(k))
  229. return Profile(path, name,
  230. member_adr, ethkey, cskey,
  231. username, email,
  232. network_url, network_realm,
  233. member_oid,
  234. vaction_oid, vaction_requested, vaction_verified,
  235. data_url, data_realm,
  236. infura_url, infura_network, infura_key, infura_secret)
  237. class UserConfig(object):
  238. """
  239. Local user configuration file. The data is either a plain text (unencrypted)
  240. .ini file, or such a file encrypted with XSalsa20-Poly1305, and with a
  241. binary file header of 48 octets.
  242. """
  243. def __init__(self, config_path):
  244. """
  245. :param config_path: The user configuration file path.
  246. """
  247. from txaio import make_logger
  248. self.log = make_logger()
  249. self._config_path = os.path.abspath(config_path)
  250. self._profiles = {}
  251. @property
  252. def config_path(self) -> List[str]:
  253. """
  254. Return the path to the user configuration file exposed by this object.,
  255. :return: Local filesystem path.
  256. """
  257. return self._config_path
  258. @property
  259. def profiles(self) -> Dict[str, object]:
  260. """
  261. Access to a map of user profiles in this user configuration.
  262. :return: Map of user profiles.
  263. """
  264. return self._profiles
  265. def save(self, password: Optional[str] = None):
  266. """
  267. Save this user configuration to the underlying configuration file. The user
  268. configuration file can be encrypted using Argon2id when a ``password`` is given.
  269. :param password: The optional Argon2id password.
  270. :return: Number of octets written to the user configuration file.
  271. """
  272. written = 0
  273. config = configparser.ConfigParser()
  274. for profile_name, profile in self._profiles.items():
  275. if profile_name not in config.sections():
  276. config.add_section(profile_name)
  277. written += 1
  278. pd = profile.marshal()
  279. for option, value in pd.items():
  280. config.set(profile_name, option, value)
  281. with io.StringIO() as fp1:
  282. config.write(fp1)
  283. config_data = fp1.getvalue().encode('utf8')
  284. if password:
  285. # binary file format header (48 bytes):
  286. #
  287. # * 8 bytes: 0xdeadbeef 0x00000666 magic number (big endian)
  288. # * 4 bytes: 0x00000001 encryption type 1 for "argon2id"
  289. # * 4 bytes data length (big endian)
  290. # * 8 bytes created timestamp ns (big endian)
  291. # * 8 bytes unused (filled 0x00 currently)
  292. # * 16 bytes salt
  293. #
  294. salt = os.urandom(16)
  295. context = 'xbrnetwork-config'
  296. priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
  297. box = nacl.secret.SecretBox(priv_key)
  298. config_data_ciphertext = box.encrypt(config_data)
  299. dl = [
  300. b'\xde\xad\xbe\xef',
  301. b'\x00\x00\x06\x66',
  302. b'\x00\x00\x00\x01',
  303. struct.pack('>L', len(config_data_ciphertext)),
  304. struct.pack('>Q', time_ns()),
  305. b'\x00' * 8,
  306. salt,
  307. config_data_ciphertext,
  308. ]
  309. data = b''.join(dl)
  310. else:
  311. data = config_data
  312. with open(self._config_path, 'wb') as fp2:
  313. fp2.write(data)
  314. self.log.debug('configuration with {sections} sections, {bytes_written} bytes written to {written_to}',
  315. sections=written, bytes_written=len(data), written_to=self._config_path)
  316. return len(data)
  317. def load(self, cb_get_password=None) -> List[str]:
  318. """
  319. Load this object from the underlying user configuration file. When the
  320. file is encrypted, call back into ``cb_get_password`` to get the user password.
  321. :param cb_get_password: Callback called when password is needed.
  322. :return: List of profiles loaded.
  323. """
  324. if not os.path.exists(self._config_path) or not os.path.isfile(self._config_path):
  325. raise RuntimeError('config path "{}" cannot be loaded: so such file'.format(self._config_path))
  326. with open(self._config_path, 'rb') as fp:
  327. data = fp.read()
  328. if len(data) >= 48 and data[:8] == b'\xde\xad\xbe\xef\x00\x00\x06\x66':
  329. # binary format detected
  330. header = data[:48]
  331. body = data[48:]
  332. algo = struct.unpack('>L', header[8:12])[0]
  333. data_len = struct.unpack('>L', header[12:16])[0]
  334. created = struct.unpack('>Q', header[16:24])[0]
  335. # created_ts = np.datetime64(created, 'ns')
  336. assert algo in [0, 1]
  337. assert data_len == len(body)
  338. assert created < time_ns()
  339. salt = header[32:48]
  340. context = 'xbrnetwork-config'
  341. if cb_get_password:
  342. password = cb_get_password()
  343. else:
  344. password = ''
  345. priv_key = pkm_from_argon2_secret(email='', password=password, context=context, salt=salt)
  346. box = nacl.secret.SecretBox(priv_key)
  347. body = box.decrypt(body)
  348. else:
  349. header = None
  350. body = data
  351. config = configparser.ConfigParser()
  352. config.read_string(body.decode('utf8'))
  353. profiles = {}
  354. for profile_name in config.sections():
  355. citems = config.items(profile_name)
  356. profile = Profile.parse(self._config_path, profile_name, citems)
  357. profiles[profile_name] = profile
  358. self._profiles = profiles
  359. loaded_profiles = sorted(self.profiles.keys())
  360. return loaded_profiles
  361. if 'CROSSBAR_FABRIC_URL' in os.environ:
  362. _DEFAULT_CFC_URL = os.environ['CROSSBAR_FABRIC_URL']
  363. else:
  364. _DEFAULT_CFC_URL = u'wss://master.xbr.network/ws'
  365. def style_error(text):
  366. if _HAS_COLOR_TERM:
  367. return click.style(text, fg='red', bold=True)
  368. else:
  369. return text
  370. def style_ok(text):
  371. if _HAS_COLOR_TERM:
  372. return click.style(text, fg='green', bold=True)
  373. else:
  374. return text
  375. class WampUrl(click.ParamType):
  376. """
  377. WAMP transport URL validator.
  378. """
  379. name = 'WAMP transport URL'
  380. def __init__(self):
  381. click.ParamType.__init__(self)
  382. def convert(self, value, param, ctx):
  383. try:
  384. parse_url(value)
  385. except Exception as e:
  386. self.fail(style_error(str(e)))
  387. else:
  388. return value
  389. def prompt_for_wamp_url(msg, default=None):
  390. """
  391. Prompt user for WAMP transport URL (eg "wss://planet.xbr.network/ws").
  392. """
  393. value = click.prompt(msg, type=WampUrl(), default=default)
  394. return value
  395. class EthereumAddress(click.ParamType):
  396. """
  397. Ethereum address validator.
  398. """
  399. name = 'Ethereum address'
  400. def __init__(self):
  401. click.ParamType.__init__(self)
  402. def convert(self, value, param, ctx):
  403. try:
  404. value = web3.Web3.toChecksumAddress(value)
  405. adr = binascii.a2b_hex(value[2:])
  406. if len(value) != 20:
  407. raise ValueError('Ethereum addres must be 20 bytes (160 bit), but was {} bytes'.format(len(adr)))
  408. except Exception as e:
  409. self.fail(style_error(str(e)))
  410. else:
  411. return value
  412. def prompt_for_ethereum_address(msg):
  413. """
  414. Prompt user for an Ethereum (public) address.
  415. """
  416. value = click.prompt(msg, type=EthereumAddress())
  417. return value
  418. class PrivateKey(click.ParamType):
  419. """
  420. Private key (32 bytes in HEX) validator.
  421. """
  422. name = 'Private key'
  423. def __init__(self, key_len):
  424. click.ParamType.__init__(self)
  425. self._key_len = key_len
  426. def convert(self, value, param, ctx):
  427. try:
  428. value = hexstr_if_str(to_hex, value)
  429. if value[:2] in ['0x', '\\x']:
  430. key = binascii.a2b_hex(value[2:])
  431. else:
  432. key = binascii.a2b_hex(value)
  433. if len(key) != self._key_len:
  434. raise ValueError('key length must be {} bytes, but was {} bytes'.format(self._key_len, len(key)))
  435. except Exception as e:
  436. self.fail(style_error(str(e)))
  437. else:
  438. return value
  439. def prompt_for_key(msg, key_len, default=None):
  440. """
  441. Prompt user for a binary key of given length (in HEX).
  442. """
  443. value = click.prompt(msg, type=PrivateKey(key_len), default=default)
  444. return value
  445. # default configuration stored in $HOME/.xbrnetwork/config.ini
  446. _DEFAULT_CONFIG = """[default]
  447. # username used with this profile
  448. username={username}
  449. # user email used with the profile (e.g. for verification emails)
  450. email={email}
  451. # XBR network node used as a directory server and gateway to XBR smart contracts
  452. network_url={network_url}
  453. # WAMP realm on network node, usually "xbrnetwork"
  454. network_realm={network_realm}
  455. # user private WAMP-cryptosign key (for client authentication)
  456. cskey={cskey}
  457. # user private Ethereum key (for signing transactions and e2e data encryption)
  458. ethkey={ethkey}
  459. """
  460. # # default XBR market URL to connect to
  461. # market_url={market_url}
  462. # market_realm={market_realm}
  463. # # Infura blockchain gateway configuration
  464. # infura_url={infura_url}
  465. # infura_network={infura_network}
  466. # infura_key={infura_key}
  467. # infura_secret={infura_secret}
  468. def load_or_create_profile(dotdir=None, profile=None, default_url=None, default_realm=None, default_email=None, default_username=None):
  469. dotdir = dotdir or '~/.xbrnetwork'
  470. profile = profile or 'default'
  471. default_url = default_url or 'wss://planet.xbr.network/ws'
  472. default_realm = default_realm or 'xbrnetwork'
  473. config_dir = os.path.expanduser(dotdir)
  474. if not os.path.isdir(config_dir):
  475. os.mkdir(config_dir)
  476. click.echo('created new local user directory {}'.format(style_ok(config_dir)))
  477. config_path = os.path.join(config_dir, 'config.ini')
  478. if not os.path.isfile(config_path):
  479. click.echo('creating new user profile "{}"'.format(style_ok(profile)))
  480. with open(config_path, 'w') as f:
  481. network_url = prompt_for_wamp_url('enter the WAMP router URL of the network directory node', default=default_url)
  482. network_realm = click.prompt('enter the WAMP realm to join on the network directory node', type=str, default=default_realm)
  483. cskey = prompt_for_key('your private WAMP client key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
  484. ethkey = prompt_for_key('your private Etherum key', 32, default='0x' + binascii.b2a_hex(os.urandom(32)).decode())
  485. email = click.prompt('user email used for with profile', type=str, default=default_email)
  486. username = click.prompt('user name used with this profile', type=str, default=default_username)
  487. f.write(_DEFAULT_CONFIG.format(network_url=network_url, network_realm=network_realm, ethkey=ethkey,
  488. cskey=cskey, email=email, username=username))
  489. click.echo('created new local user configuration {}'.format(style_ok(config_path)))
  490. config_obj = UserConfig(config_path)
  491. config_obj.load()
  492. profile_obj = config_obj.profiles.get(profile, None)
  493. if not profile_obj:
  494. raise click.ClickException('no such profile: "{}"'.format(profile))
  495. return profile_obj