19 Commits

Author SHA1 Message Date
618aa7716e fix: bad conditional 2022-04-27 12:43:22 +03:00
17ae29887f fix: bump dependencies 2022-04-27 10:12:28 +03:00
fbb5168b40 Release v0.0.7 2022-03-31 12:40:41 +03:00
c269318528 Merge pull request 'fix: make store path relative to $HOME' (#17) from lum/store_path into master
Reviewed-on: #17
2022-03-31 09:34:47 +00:00
c3e8883f15 fix: make store path relative to $HOME 2022-03-31 12:33:29 +03:00
5f62287913 docs: add gpg instructions and example
note: private key in example is publicly available in cic-stack
2022-02-23 19:38:35 +03:00
28d7699a4a Merge pull request 'docs: add readme' (#16) from sohail/docs into master
Reviewed-on: #16
2022-02-14 07:01:46 +00:00
05224a9dd6 docs: add readme 2022-02-02 18:14:31 +03:00
2f65aa37ff Merge pull request 'Release 0.0.6' (#13) from lash/0.0.6 into master
release: v0.0.6
2022-01-26 06:24:39 +00:00
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
14 changed files with 235 additions and 22 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ __pycache__
*.egg-info *.egg-info
build/ build/
*.pyc *.pyc
.venv
.clicada
dist/

View File

@@ -1,3 +1,7 @@
- 0.0.7
* fix: make store_path relative to the users home
- 0.0.6
* Add cache encryption, with AES-CTR-128
- 0.0.5 - 0.0.5
* Replace logs with colorized progress output on default loglevel * Replace logs with colorized progress output on default loglevel
* Do not repeat already failed metadata lookups * Do not repeat already failed metadata lookups

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
## Clicada
> Admin Command Line Interface to interact with cic-meta and cic-cache
### Pre-requisites
- Public key uploaded to `cic-auth-helper`
- PGP Keyring for your key
### Installation
Use either of the following installation methods:
1. Install from git release (recommended)
```bash
wget https://git.grassecon.net/grassrootseconomics/clicada/archive/v0.0.6.zip
unzip clicada-v0.0.6.zip
cd clicada
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt --extra-index-url=https://pip.grassrootseconomics.net
```
2. Install from pip to path (non sudo)
```bash
pip3 install -UI --extra-index-url=https://pip.grassrootseconomics.net clicada
```
### GPG Keyring setup
PGP uses the default keyring, you can however pass in a custom keyring path.
To create a keyring from a specific key and get its path for `AUTH_KEYRING_PATH`:
```bash
# In some dir
gpg --homedir .gnupg --import private.pgp
pwd
```
### Usage
```bash
usage: clicada [...optional arguments] [...positional arguments]
positional arguments:
{user,u,tag,t}
user (u) retrieve transactions for a user
tag (t) locally assign a display value to an identifier
optional arguments:
-h, --help show this help message and exit
--no-logs Turn off all logging
-v Be verbose
-vv Be very verbose
-c CONFIG, --config CONFIG
Configuration directory
-n NAMESPACE, --namespace NAMESPACE
Configuration namespace
--dumpconfig {env,ini}
Output configuration and quit. Use with --raw to omit values and output schema only.
--env-prefix ENV_PREFIX
environment prefix for variables to overwrite configuration
-p P, --rpc-provider P
RPC HTTP(S) provider url
--rpc-dialect RPC_DIALECT
RPC HTTP(S) backend dialect
--height HEIGHT Block height to execute against
-i I, --chain-spec I Chain specification string
-u, --unsafe Do not verify address checksums
--seq Use sequential rpc ids
-y Y, --key-file Y Keystore file to use for signing or address
--raw Do not decode output
--fee-price FEE_PRICE
override fee price
--fee-limit FEE_LIMIT
override fee limit
```
### Example
```bash
AUTH_PASSPHRASE=queenmarlena AUTH_KEYRING_PATH=/home/kamikaze/grassroots/usumbufu/tests/testdata/pgp/.gnupg/ AUTH_KEY=CCE2E1D2D0E36ADE0405E2D0995BB21816313BD5 CHAIN_SPEC=evm:byzantium:8996:bloxberg CIC_REGISTRY_ADDRESS=0xcf60ebc445b636a5ab787f9e8bc465a2a3ef8299 RPC_PROVIDER=https://rpc.grassecon.net TX_CACHE_URL=https://cache.grassecon.net HTTP_CORS_ORIGIN=https://auth.grassecon.net META_HTTP_ORIGIN=https://auth.grassecon.net:443 PYTHONPATH=. python clicada/runnable/view.py u --meta-url https://auth.grassecon.net +254711000000
```

View File

@@ -1,7 +1,7 @@
# import notifier # import notifier
from clicada.cli.notify import NotifyWriter from clicada.cli.notify import NotifyWriter
notifier = NotifyWriter() notifier = NotifyWriter()
notifier.notify('loading script') #notifier.notify('loading script')
# standard imports # standard imports
import os import os
@@ -22,6 +22,7 @@ from clicada.cli.http import (
HTTPSession, HTTPSession,
PGPClientSession, PGPClientSession,
) )
from clicada.crypt.aes import AESCTREncrypt
logg = logging.getLogger() logg = logging.getLogger()
@@ -150,6 +151,7 @@ class CmdCtrl:
auth_db_path = self.get('AUTH_DB_PATH', default_auth_db_path) 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 = PGPAuthCrypt(auth_db_path, self.get('AUTH_KEY'), self.get('AUTH_KEYRING_PATH'))
self.__auth.get_secret(self.get('AUTH_PASSPHRASE')) self.__auth.get_secret(self.get('AUTH_PASSPHRASE'))
self.encrypter = AESCTREncrypt(auth_db_path, self.__auth.secret)
def get(self, k, default=None): def get(self, k, default=None):

View File

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

View File

@@ -3,6 +3,7 @@ import hashlib
import urllib.parse import urllib.parse
import os import os
import logging import logging
from socket import getservbyname
# external imports # external imports
from usumbufu.client.base import ( from usumbufu.client.base import (
@@ -48,7 +49,15 @@ class HTTPSession:
def __init__(self, url, auth=None, origin=None): def __init__(self, url, auth=None, origin=None):
self.base_url = url self.base_url = url
url_parts = urllib.parse.urlsplit(self.base_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 self.origin = origin
if self.origin == None: if self.origin == None:
self.origin = urllib.parse.urlunsplit(url_parts_origin) 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

@@ -3,7 +3,8 @@ import json
# external imports # external imports
from clicada.user import FileUserStore from clicada.user import FileUserStore
from pathlib import Path
import os
categories = [ categories = [
'phone', 'phone',
@@ -35,7 +36,7 @@ def validate(config, args):
def execute(ctrl): def execute(ctrl):
store_path = '.clicada' store_path = os.path.join(str(Path.home()), '.clicada')
user_store = FileUserStore(None, ctrl.chain(), ctrl.get('_CATEGORY'), store_path, int(ctrl.get('FILESTORE_TTL'))) user_store = FileUserStore(None, ctrl.chain(), ctrl.get('_CATEGORY'), store_path, int(ctrl.get('FILESTORE_TTL')))
user_store.put(ctrl.get('_IDENTIFIER'), json.dumps(ctrl.get('_TAG')), force=True) user_store.put(ctrl.get('_IDENTIFIER'), json.dumps(ctrl.get('_TAG')), force=True)
user_store.stick(ctrl.get('_IDENTIFIER')) user_store.stick(ctrl.get('_IDENTIFIER'))

View File

@@ -2,6 +2,8 @@
import sys import sys
import logging import logging
import datetime import datetime
from pathlib import Path
import os
# external imports # external imports
from cic_eth_registry import CICRegistry from cic_eth_registry import CICRegistry
@@ -54,9 +56,9 @@ def validate(config, args):
def execute(ctrl): def execute(ctrl):
tx_getter = TxGetter(ctrl.get('TX_CACHE_URL'), 10) tx_getter = TxGetter(ctrl.get('TX_CACHE_URL'), 10)
store_path = '.clicada' store_path = os.path.join(str(Path.home()), '.clicada')
user_phone_file_label = 'phone' 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'))) 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')) user_address = user_phone_store.by_phone(ctrl.get('_IDENTIFIER'), update=ctrl.get('_FORCE'))
@@ -78,7 +80,7 @@ def execute(ctrl):
token_store = FileTokenStore(ctrl.chain(), ctrl.conn(), 'token', store_path) token_store = FileTokenStore(ctrl.chain(), ctrl.conn(), 'token', store_path)
user_address_file_label = 'address' 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)
ctrl.notify('resolving metadata for address {}'.format(user_address_normal)) ctrl.notify('resolving metadata for address {}'.format(user_address_normal))
try: try:

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

@@ -65,7 +65,7 @@ class Account(Person):
class FileUserStore: 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) invalidate_before = datetime.datetime.now() - datetime.timedelta(seconds=ttl)
self.invalidate_before = int(invalidate_before.timestamp()) self.invalidate_before = int(invalidate_before.timestamp())
self.have_xattr = False self.have_xattr = False
@@ -82,6 +82,7 @@ class FileUserStore:
self.__validate_dir() self.__validate_dir()
self.metadata_opener = metadata_opener self.metadata_opener = metadata_opener
self.failed_entities = {} self.failed_entities = {}
self.encrypter = encrypter
def __validate_dir(self): def __validate_dir(self):
@@ -108,8 +109,14 @@ class FileUserStore:
if have_file and not its_time and not force: if have_file and not its_time and not force:
raise FileExistsError('user resolution already exists for {}'.format(k)) raise FileExistsError('user resolution already exists for {}'.format(k))
f = open(p, 'w') ve = v
f.write(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() f.close()
logg.info('added user store {} record {} -> {}'.format(self.label, k, v)) logg.info('added user store {} record {} -> {}'.format(self.label, k, v))
@@ -174,12 +181,21 @@ class FileUserStore:
self.__unstick(p) self.__unstick(p)
self.check_expiry(p) self.check_expiry(p)
f = open(p, 'r') f = None
r = f.read() if self.encrypter != None:
f = open(p, 'rb')
else:
f = open(p, 'r')
v = f.read()
f.close() 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)) logg.debug('retrieved {} from {}'.format(k, p))
return r.strip() return v.strip()
def by_phone(self, phone, update=False): def by_phone(self, phone, update=False):
@@ -253,7 +269,7 @@ class FileUserStore:
except Exception as e: except Exception as e:
logg.debug('no metadata found for {}: {}'.format(address, e)) logg.debug('no metadata found for {}: {}'.format(address, e))
if r == None: if not r:
self.failed_entities[address] = True self.failed_entities[address] = True
raise MetadataNotFoundError() raise MetadataNotFoundError()

View File

@@ -1,7 +1,10 @@
usumbufu~=0.3.4 usumbufu~=0.3.8
confini~=0.5.1 confini~=0.6.0
cic-eth-registry~=0.6.1 cic-eth-registry~=0.6.9
cic-types~=0.2.1a8 cic-types~=0.2.2
phonenumbers==8.12.12 phonenumbers==8.12.12
eth-erc20~=0.1.2 eth-erc20~=0.3.0
hexathon~=0.1.0 hexathon~=0.1.0
pycryptodome~=3.10.1
chainlib-eth~=0.1.0
chainlib~=0.1.0

View File

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