16 Commits

Author SHA1 Message Date
lash
be7dea24ac Release 0.0.6 2022-01-24 17:03:54 +00:00
65b3d4d409 Merge pull request 'feat: Add cache encryption' (#9) from lash/encrypt into master
Reviewed-on: #9
2022-01-23 06:59:16 +00:00
lash
0bfe054b90 Auto-complete origin on missing port, magic shutil terminal size 2022-01-21 14:38:06 +00:00
lash
fe410e0fc6 Merge remote-tracking branch 'origin/master' into lash/encrypt 2022-01-21 11:29:41 +00:00
lash
3663665d91 Update packaging and deps 2022-01-21 11:28:22 +00:00
b0f8f39d15 usability: WIP Friendly progress output (#4)
This MR adds colorized progress statements when resolving and retrieving data for user. It disables logging if loglevel is set to default (logging.WARNING at this time).

It also skips metadata lookups that have failed in the same session, speeding up retrievals when same address repeatedly occurs in transaction list.

closes #6
closes #5

Co-authored-by: lash <dev@holbrook.no>
Reviewed-on: #4
Co-authored-by: lash <accounts-grassrootseconomics@holbrook.no>
Co-committed-by: lash <accounts-grassrootseconomics@holbrook.no>
2022-01-21 11:11:23 +00:00
lash
140df0d1c6 Add pycryptodome dep 2022-01-21 11:09:17 +00:00
lash
c5b4c41db0 Bump version 2022-01-21 11:04:17 +00:00
lash
d302c5754c Add AES CTR with key as iv 2022-01-21 10:59:27 +00:00
lash
36b4fcab93 Add back crypt module 2022-01-21 10:15:43 +00:00
lash
f17e31d801 Return address on shortcircuited dud lookup 2022-01-21 10:13:11 +00:00
lash
64c7fa950c Clean pollution from encrypt branch 2022-01-21 10:06:29 +00:00
lash
b057cb65ff WIP add AES to local cache 2022-01-21 10:03:01 +00:00
lash
ce084bcb48 Bump version 2022-01-20 14:09:41 +00:00
lash
b7ea579aa5 Upgrade deps 2022-01-20 14:09:14 +00:00
nolash
6988c8c2b9 Bump version 2022-01-01 10:11:17 +00:00
13 changed files with 255 additions and 63 deletions

View File

@@ -1,3 +1,12 @@
- 0.0.6
* Add cache encryption, with AES-CTR-128
- 0.0.5
* Replace logs with colorized progress output on default loglevel
* Do not repeat already failed metadata lookups
- 0.0.4
* Resolve metadata to labels when loading from cache
- 0.0.3
* Upgrade usumbufu to prevent missing bearer auth on https
- 0.0.2
* Use ~/.config for default config override
- 0.0.1-unreleased

View File

@@ -1,8 +1,13 @@
# import notifier
from clicada.cli.notify import NotifyWriter
notifier = NotifyWriter()
#notifier.notify('loading script')
# standard imports
import os
#import argparse
import logging
import importlib
import sys
# external imports
import confini
@@ -17,6 +22,7 @@ from clicada.cli.http import (
HTTPSession,
PGPClientSession,
)
from clicada.crypt.aes import AESCTREncrypt
logg = logging.getLogger()
@@ -25,6 +31,20 @@ data_dir = os.path.join(script_dir, '..', 'data')
base_config_dir = os.path.join(data_dir, 'config')
class NullWriter:
def notify(self, v):
pass
def ouch(self, v):
pass
def write(self, v):
sys.stdout.write(str(v))
class CmdCtrl:
__cmd_alias = {
@@ -45,10 +65,12 @@ class CmdCtrl:
self.config()
self.notifier()
self.auth()
self.blockchain()
self.remote_openers = {}
if self.get('META_URL') != None:
auth_client_session = PGPClientSession(self.__auth)
@@ -129,6 +151,7 @@ class CmdCtrl:
auth_db_path = self.get('AUTH_DB_PATH', default_auth_db_path)
self.__auth = PGPAuthCrypt(auth_db_path, self.get('AUTH_KEY'), self.get('AUTH_KEYRING_PATH'))
self.__auth.get_secret(self.get('AUTH_PASSPHRASE'))
self.encrypter = AESCTREncrypt(auth_db_path, self.__auth.secret)
def get(self, k, default=None):
@@ -156,3 +179,27 @@ class CmdCtrl:
def opener(self, k):
return self.remote_openers[k]
def notifier(self):
if logg.root.level >= logging.WARNING:
logging.disable()
self.writer = notifier
else:
self.writer = NullWriter()
def notify(self, v):
self.writer.notify(v)
def ouch(self, v):
self.writer.ouch(v)
print()
def write(self, v):
self.writer.write("")
self.writer.write(v)
print()

View File

@@ -26,6 +26,7 @@ class PGPAuthCrypt:
raise AuthError('invalid key {}'.format(auth_key))
self.auth_key = auth_key
self.gpg = gnupg.GPG(gnupghome=pgp_dir)
self.secret = None
def get_secret(self, passphrase=''):
@@ -49,10 +50,11 @@ class PGPAuthCrypt:
f.write(secret.data)
f.close()
f = open(p, 'rb')
self.secret = self.gpg.decrypt_file(f, passphrase=passphrase)
if not self.secret.ok:
secret = self.gpg.decrypt_file(f, passphrase=passphrase)
if not secret.ok:
raise AuthError('could not decrypt encryption secret. wrong password?')
f.close()
self.secret = secret.data
self.__passphrase = passphrase

View File

@@ -3,6 +3,7 @@ import hashlib
import urllib.parse
import os
import logging
from socket import getservbyname
# external imports
from usumbufu.client.base import (
@@ -48,7 +49,15 @@ class HTTPSession:
def __init__(self, url, auth=None, origin=None):
self.base_url = url
url_parts = urllib.parse.urlsplit(self.base_url)
url_parts_origin = (url_parts[0], url_parts[1], '', '', '',)
url_parts_origin_host = url_parts[1].split(":")
host = url_parts_origin_host[0]
try:
host = host + ':' + url_parts_origin_host[1]
except IndexError:
host = host + ':' + str(getservbyname(url_parts[0]))
logg.info('changed origin with missing port number from {} to {}'.format(url_parts[1], host))
url_parts_origin = (url_parts[0], host, '', '', '',)
self.origin = origin
if self.origin == None:
self.origin = urllib.parse.urlunsplit(url_parts_origin)

33
clicada/cli/notify.py Normal file
View File

@@ -0,0 +1,33 @@
# standard imports
import os
import sys
import shutil
class NotifyWriter:
def __init__(self, writer=sys.stdout):
(c, r) = shutil.get_terminal_size()
self.cols = c
self.fmt = "\r{:" + "<{}".format(c) + "}"
self.w = writer
self.notify_max = self.cols - 4
def notify(self, v):
if len(v) > self.notify_max:
v = v[:self.notify_max]
self.write('\x1b[0;36m... ' + v + '\x1b[0;39m')
def ouch(self, v):
if len(v) > self.notify_max:
v = v[:self.notify_max]
self.write('\x1b[0;91m!!! ' + v + '\x1b[0;39m')
def write(self, v):
s = str(v)
if len(s) > self.cols:
s = s[:self.cols]
self.w.write(self.fmt.format(s))

View File

@@ -19,6 +19,7 @@ from clicada.token import (
token_balance,
)
from clicada.tx import ResolvedTokenTx
from clicada.error import MetadataNotFoundError
logg = logging.getLogger(__name__)
@@ -51,37 +52,42 @@ def validate(config, args):
def execute(ctrl):
tx_getter = TxGetter(ctrl.get('TX_CACHE_URL'))
tx_getter = TxGetter(ctrl.get('TX_CACHE_URL'), 10)
store_path = '.clicada'
user_phone_file_label = 'phone'
user_phone_store = FileUserStore(ctrl.opener('meta'), ctrl.chain(), user_phone_file_label, store_path, int(ctrl.get('FILESTORE_TTL')))
user_phone_store = FileUserStore(ctrl.opener('meta'), ctrl.chain(), user_phone_file_label, store_path, int(ctrl.get('FILESTORE_TTL')), encrypter=ctrl.encrypter)
ctrl.notify('resolving identifier {} to wallet address'.format(ctrl.get('_IDENTIFIER')))
user_address = user_phone_store.by_phone(ctrl.get('_IDENTIFIER'), update=ctrl.get('_FORCE'))
if user_address == None:
sys.stderr.write('unknown identifier: {}\n'.format(ctrl.get('_IDENTIFIER')))
ctrl.ouch('unknown identifier: {}\n'.format(ctrl.get('_IDENTIFIER')))
sys.exit(1)
try:
user_address = to_checksum_address(user_address)
except ValueError:
sys.stderr.write('invalid response "{}" for {}\n'.format(user_address, ctrl.get('_IDENTIFIER')))
ctrl.ouch('invalid response "{}" for {}\n'.format(user_address, ctrl.get('_IDENTIFIER')))
sys.exit(1)
logg.debug('loaded user address {} for {}'.format(user_address, ctrl.get('_IDENTIFIER')))
user_address_normal = tx_normalizer.wallet_address(user_address)
ctrl.notify('retrieving txs for address {}'.format(user_address_normal))
txs = tx_getter.get(user_address)
token_store = FileTokenStore(ctrl.chain(), ctrl.conn(), 'token', store_path)
user_address_file_label = 'address'
user_address_store = FileUserStore(ctrl.opener('meta'), ctrl.chain(), user_address_file_label, store_path, int(ctrl.get('FILESTORE_TTL')))
user_address_store = FileUserStore(ctrl.opener('meta'), ctrl.chain(), user_address_file_label, store_path, int(ctrl.get('FILESTORE_TTL')), encrypter=ctrl.encrypter)
user_address_normal = tx_normalizer.wallet_address(user_address)
ctrl.notify('resolving metadata for address {}'.format(user_address_normal))
try:
r = user_address_store.by_address(user_address_normal, update=ctrl.get('_FORCE'))
except MetadataNotFoundError as e:
ctrl.ouch('could not resolve metadata for user: {}'.format(e))
sys.exit(1)
r = user_address_store.by_address(user_address_normal, update=ctrl.get('_FORCE'))
print('r {}'.format(r))
print("""Phone: {}
ctrl.write("""Phone: {}
Network address: {}
Chain: {}
Name: {}
@@ -89,8 +95,7 @@ Registered: {}
Gender: {}
Location: {}
Products: {}
Tags: {}
Balances:""".format(
Tags: {}""".format(
ctrl.get('_IDENTIFIER'),
add_0x(user_address),
ctrl.chain().common_name(),
@@ -106,6 +111,7 @@ Balances:""".format(
tx_lines = []
seen_tokens = {}
for tx_src in txs['data']:
ctrl.notify('resolve details for tx {}'.format(tx_src['tx_hash']))
tx = ResolvedTokenTx.from_dict(tx_src)
tx.resolve(token_store, user_address_store, show_decimals=True, update=ctrl.get('_FORCE'))
tx_lines.append(tx)
@@ -113,12 +119,14 @@ Balances:""".format(
seen_tokens[tx.destination_token_label] = tx.destination_token
for k in seen_tokens.keys():
ctrl.notify('resolve token {}'.format(seen_tokens[k]))
(token_symbol, token_decimals) = token_store.by_address(seen_tokens[k])
ctrl.notify('get token balance for {} => {}'.format(token_symbol, seen_tokens[k]))
balance = token_balance(ctrl.chain(), ctrl.conn(), seen_tokens[k], user_address)
fmt = '{:.' + str(token_decimals) + 'f}'
decimal_balance = fmt.format(balance / (10 ** token_decimals))
print("\t{} {}".format(token_symbol, decimal_balance))
ctrl.write("Balances:\n {} {}".format(token_symbol, decimal_balance))
print()
for l in tx_lines:
print(l)
ctrl.write(l)

42
clicada/crypt/aes.py Normal file
View File

@@ -0,0 +1,42 @@
# standard imports
import os
import logging
import hashlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
from .base import Encrypter
logg = logging.getLogger(__name__)
class AESCTREncrypt(Encrypter):
aes_block_size = 1 << 7
counter_bytes = int(128 / 8)
def __init__(self, db_dir, secret):
self.secret = secret
def key_to_iv(self, k):
h = hashlib.sha256()
h.update(k.encode('utf-8'))
h.update(self.secret)
z = h.digest()
return int.from_bytes(z[:self.counter_bytes], 'big')
def encrypt(self, k, v):
iv = self.key_to_iv(k)
ctr = Counter.new(self.aes_block_size, initial_value=iv)
cipher = AES.new(self.secret, AES.MODE_CTR, counter=ctr)
return cipher.encrypt(v)
def decrypt(self, k, v):
iv = self.key_to_iv(k)
ctr = Counter.new(self.aes_block_size, initial_value=iv)
cipher = AES.new(self.secret, AES.MODE_CTR, counter=ctr)
return cipher.decrypt(v)

8
clicada/crypt/base.py Normal file
View File

@@ -0,0 +1,8 @@
class Encrypter:
def encrypt(self, v):
raise NotImplementedError()
def decrypt(self, v):
raise NotImplementedError()

View File

@@ -4,3 +4,7 @@ class ExpiredRecordError(Exception):
class AuthError(Exception):
pass
class MetadataNotFoundError(Exception):
pass

View File

@@ -12,7 +12,10 @@ from cic_types.models.tx import (
# local imports
from clicada.encode import tx_normalize
from clicada.error import ExpiredRecordError
from clicada.error import (
ExpiredRecordError,
MetadataNotFoundError,
)
logg = logging.getLogger(__name__)
@@ -56,35 +59,22 @@ class ResolvedTokenTx(TokenTx):
self.to_value_label = fmt.format(token_value)
def resolve_stored_entity(self, user_store, address, update=False):
if update:
return None
address = tx_normalize.wallet_address(address)
def resolve_entity(self, user_store, address):
try:
v = user_store.get(address)
return v
except FileNotFoundError:
return None
except ExpiredRecordError:
return None
r = user_store.by_address(address)
except MetadataNotFoundError:
return address
return str(r)
def resolve_sender_entity(self, user_store, update=False):
v = self.resolve_stored_entity(user_store, self.sender, update=update)
if v != None:
return v
if self.tx_type == TokenTxType.faucet_giveto.value:
return 'FAUCET'
r = user_store.by_address(self.sender)
return str(r)
return self.resolve_entity(user_store, self.sender)
def resolve_recipient_entity(self, user_store, update=False):
v = self.resolve_stored_entity(user_store, self.recipient, update=update)
if v != None:
return v
r = user_store.by_address(self.recipient, update=update)
return str(r)
return self.resolve_entity(user_store, self.recipient)
def resolve_entities(self, user_store, update=False):
@@ -99,7 +89,7 @@ class ResolvedTokenTx(TokenTx):
def __str__(self):
if self.symmetric:
return '{}\t{} => {}\t{} {}'.format(
return '{} {} => {} {} {}'.format(
self.date_block_label,
self.sender_label,
self.recipient_label,

View File

@@ -18,7 +18,10 @@ import phonenumbers
# local imports
from clicada.encode import tx_normalize
from clicada.store.mem import MemDictStore
from clicada.error import ExpiredRecordError
from clicada.error import (
ExpiredRecordError,
MetadataNotFoundError,
)
logg = logging.getLogger(__name__)
@@ -62,7 +65,7 @@ class Account(Person):
class FileUserStore:
def __init__(self, metadata_opener, chain_spec, label, store_base_path, ttl):
def __init__(self, metadata_opener, chain_spec, label, store_base_path, ttl, encrypter=None):
invalidate_before = datetime.datetime.now() - datetime.timedelta(seconds=ttl)
self.invalidate_before = int(invalidate_before.timestamp())
self.have_xattr = False
@@ -78,6 +81,8 @@ class FileUserStore:
os.makedirs(self.store_path, exist_ok=True)
self.__validate_dir()
self.metadata_opener = metadata_opener
self.failed_entities = {}
self.encrypter = encrypter
def __validate_dir(self):
@@ -86,6 +91,10 @@ class FileUserStore:
logg.debug('using existing file store {} for {}'.format(self.store_path, self.label))
def is_dud(self, address):
return bool(self.failed_entities.get(address))
def put(self, k, v, force=False):
have_file = False
p = os.path.join(self.store_path, k)
@@ -100,8 +109,14 @@ class FileUserStore:
if have_file and not its_time and not force:
raise FileExistsError('user resolution already exists for {}'.format(k))
f = open(p, 'w')
f.write(v)
ve = v
f = None
if self.encrypter != None:
ve = self.encrypter.encrypt(k, ve.encode('utf-8'))
f = open(p, 'wb')
else:
f = open(p, 'w')
f.write(ve)
f.close()
logg.info('added user store {} record {} -> {}'.format(self.label, k, v))
@@ -166,12 +181,21 @@ class FileUserStore:
self.__unstick(p)
self.check_expiry(p)
f = open(p, 'r')
r = f.read()
f = None
if self.encrypter != None:
f = open(p, 'rb')
else:
f = open(p, 'r')
v = f.read()
f.close()
if self.encrypter != None:
v = self.encrypter.decrypt(k, v)
logg.debug('>>>>>>>>>>>>< v decoded {}'.format(v))
v = v.decode('utf-8')
logg.debug('retrieved {} from {}'.format(k, p))
return r.strip()
return v.strip()
def by_phone(self, phone, update=False):
@@ -205,28 +229,37 @@ class FileUserStore:
self.put(phone_file, user_address, force=update)
return user_address
def metadata_to_person(self, v):
person = Account()
try:
person_data = person.deserialize(person_data=v)
except Exception as e:
person_data = v
return person_data
def by_address(self, address, update=False):
add = tx_normalize.wallet_address(address)
address = tx_normalize.wallet_address(address)
address = strip_0x(address)
#if self.failed_entities.get(address):
if self.is_dud(address):
logg.debug('already tried and failed {}, skipping'.format(address))
return address
ignore_expired = self.sticky(address)
if not update:
try:
v = self.get(address, ignore_expired=ignore_expired)
v = json.loads(v)
person = Account()
try:
person_data = person.deserialize(person_data=v)
except Exception as e:
person_data = v
return person_data
return self.metadata_to_person(v)
except FileNotFoundError:
pass
except ExpiredRecordError as e:
logg.info(e)
pass
address = strip_0x(address)
getter = self.metadata_opener
ptr = generate_metadata_pointer(bytes.fromhex(address), MetadataPointer.PERSON)
@@ -235,7 +268,10 @@ class FileUserStore:
r = getter.open(ptr)
except Exception as e:
logg.debug('no metadata found for {}: {}'.format(address, e))
return address
if r == None:
self.failed_entities[address] = True
raise MetadataNotFoundError()
data = json.loads(r)
person = Account()

View File

@@ -1,7 +1,10 @@
usumbufu~=0.3.3a3
confini~=0.5.1
usumbufu~=0.3.5
confini~=0.5.3
cic-eth-registry~=0.6.1
cic-types~=0.2.1a5
cic-types~=0.2.1a8
phonenumbers==8.12.12
eth-erc20~=0.1.2
hexathon~=0.1.0
pycryptodome~=3.10.1
chainlib-eth~=0.0.21
chainlib~=0.0.17

View File

@@ -1,6 +1,6 @@
[metadata]
name = clicada
version = 0.0.2a2
version = 0.0.6
description = CLI CRM tool for the cic-stack custodial wallet system
author = Louis Holbrook
author_email = dev@holbrook.no
@@ -34,3 +34,4 @@ packages =
clicada.cli
clicada.tx
clicada.user
clicada.crypt