usawa

Signed, immutable accounting.
Log | Files | Refs | Submodules | LICENSE

commit 78f77e6387ed25dd826afed43df0d3ae9dd83332
parent 32abeea5648f659a1f891cfc9238aa48293de7a2
Author: Carlosokumu <carlosokumu254@gmail.com>
Date:   Fri, 13 Mar 2026 20:01:44 +0300

merge lash/store-key

Diffstat:
Mdummy/tests/account.py | 21++++++++++++---------
Mdummy/tests/store.py | 36++++++++++++++++++++++++++++++++++++
Mdummy/usawa/account.py | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mdummy/usawa/config.py | 2++
Mdummy/usawa/crypto.py | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mdummy/usawa/data/usawa.ini | 1+
Mdummy/usawa/ledger.py | 9+++++++++
Mdummy/usawa/runnable/add.py | 7+++++--
Mdummy/usawa/runnable/create.py | 8++++++--
Mdummy/usawa/runnable/export.py | 7+++++--
Mdummy/usawa/runnable/import.py | 2++
Mdummy/usawa/runnable/server.py | 1+
Mdummy/usawa/runnable/view.py | 7+++++--
Mdummy/usawa/service.py | 12++++--------
Mdummy/usawa/storage/ledger_repository.py | 45+++++++++++++++++++++++++++++++++------------
Mdummy/usawa/store.py | 51+++++++++++++++++++++++++++++++++++++++------------
16 files changed, 268 insertions(+), 57 deletions(-)

diff --git a/dummy/tests/account.py b/dummy/tests/account.py @@ -20,21 +20,24 @@ class TestAccount(unittest.TestCase): def test_account_lock(self): idx = AccountIndex(self.uidx) - idx.add('FOO', 'bar.baz') - idx.add('FOO', 'bar.baz') + # missing account type + with self.assertRaises(AttributeError): + idx.add('bar/baz', sym='FOO') + idx.add('liability/bar/baz', sym='FOO') + idx.add('liability/bar/baz', sym='FOO') with self.assertRaises(AccountError): - idx.add('FOO', 'bar.baz-') + idx.add('asset/bar/baz-', sym='FOO') with self.assertRaises(AccountError): - idx.add('BAZ', 'foo.bar') - self.assertFalse(idx.check('BAR', 'foo.baz')) - self.assertTrue(idx.check('FOO', 'bar.baz')) + idx.add('asset/foo/bar', sym='BAZ') + self.assertFalse(idx.check('BAR', 'liability/foo/baz')) + self.assertTrue(idx.check('FOO', 'liability/bar/baz')) def test_account_list(self): idx = AccountIndex(self.uidx) - idx.add('FOO', 'bar.bar') - idx.add('FOO', 'bar.baz') - idx.add('BAR', 'foo.baz') + idx.add('asset/bar/bar', sym='FOO') + idx.add('liability/bar/baz', sym='FOO') + idx.add('asset/foo/baz', sym='BAR') v = list(idx) logg.debug('results {}'.format(v)) self.assertEqual(len(v), 3) diff --git a/dummy/tests/store.py b/dummy/tests/store.py @@ -121,5 +121,41 @@ class TestStore(unittest.TestCase): self.assertEqual(len(ledger.entries), 2) + def test_store_restore(self): + uidx = UnitIndex('FOO') + wallet = DemoWallet() + acl = ACL.from_wallet(wallet) + ledger = Ledger(uidx, base=self.parent, acl=acl, wallet=wallet) + store = LedgerStore(self.store, ledger) + + dst = EntryPart('FOO', 'asset', 'foo', 1337) + src = EntryPart('FOO', 'income', 'foo', 1337, debit=True) + o = Entry(ledger.next_serial(), datetime.datetime.strptime('2025-11-11', '%Y-%m-%d'), parent=self.parent, ref=self.ref, description=self.description, tx_datereg=self.dtreg, unitindex=uidx) + o.add_part(src, debit=True) + o.add_part(dst) + o.sign(wallet) + store.add_entry(o) + + ref = str(uuid.uuid4()) + parent = o.sum()[0] + description = 'barbarbar' + dtreg = datetime.datetime.now() + dst = EntryPart('FOO', 'expense', 'bar', 4200) + src = EntryPart('FOO', 'liability', 'bar', 4200, debit=True) + o = Entry(ledger.next_serial(), datetime.datetime.strptime('2025-11-12', '%Y-%m-%d'), parent=parent, ref=ref, description=description, tx_datereg=dtreg, unitindex=uidx) + o.add_part(src, debit=True) + o.add_part(dst) + o.sign(wallet) + store.add_entry(o) + + ledger.truncate() + s = ledger.to_string() + ledger = Ledger.from_string(s) + store = LedgerStore(self.store, ledger) + store.restore() + + self.assertEqual(len(ledger.entries), 2) + + if __name__ == '__main__': unittest.main() diff --git a/dummy/usawa/account.py b/dummy/usawa/account.py @@ -1,3 +1,4 @@ +import enum import logging from .error import AccountError @@ -5,32 +6,81 @@ from .error import AccountError logg = logging.getLogger('account') -def default_check(path): - parts = path.split('.') +def check_path_parts(path): + parts = path.split('/') for v in parts: if not v.isalnum(): raise AccountError('invalid part: ' + v) - return True + #return True + typ = getattr(AccountType, parts[0].lower()) + return (typ, parts,) + + +def from_account_path(p, sym=None, typ=None): + if sym != None: + p = sym + '.' + p + o = p.split('.') + logg.debug('have {} {} {}'.format(p, sym, typ)) + if len(o) != 2: + raise ValueError('account path should have zero or one symbol specifier') + sym = o[0] + o = check_path_parts(o[1]) + typ = o[0] + path = o[1] + + return (sym, typ, path,) + + +class AccountType(enum.Enum): + liability = 'Liability' + asset = 'Asset' + income = 'Income' + expense = 'Expense' + imprt = 'Import' + export = 'Export' + + +class Account: + + path_parser = from_account_path + + def __init__(self, sym, typ, segments): + if not isinstance(typ, AccountType): + raise ValueError('invalid account type') + self.sym = sym + self.typ = typ + self.segments = segments + + + @staticmethod + def from_path(path, sym=None, typ=None): + o = Account.path_parser(path, sym=sym, typ=typ) + return Account(o[0], o[1], o[2]) + + + def to_path(self): + path = self.segments.join('/') + path = '{}.{}/{}'.format(self.sym, self.typ, path) class AccountIndex: - def __init__(self, unitindex, pathvalidator=default_check): + def __init__(self, unitindex): #, pathvalidator=default_check): self.uidx = unitindex self.accounts = {} self.locked = False - self.validate = pathvalidator + #self.validate = pathvalidator self.iterval = None - def add(self, sym, path): + def add(self, path, sym=None, typ=None): + account = Account.from_path(path, sym=sym, typ=typ) try: - sym = self.uidx.sym(sym) + sym = self.uidx.sym(account.sym) except KeyError: raise AccountError('unknown unit ' + sym) if self.locked: raise AccountError('account index locked') - self.validate(path) if self.accounts.get(sym) == None: self.accounts[sym] = [] elif path in self.accounts[sym]: diff --git a/dummy/usawa/config.py b/dummy/usawa/config.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) def load(): cfg = confini.Config(__datadir, env_prefix='USAWA') + cfg.censor('SIGS_KEY_PASSPHRASE') + cfg.censor('SIGS_DEFAULT_PRIVATE_KEY') cfg.process() if "VALKEY_HOST" in cfg.store: diff --git a/dummy/usawa/crypto.py b/dummy/usawa/crypto.py @@ -1,11 +1,15 @@ import logging +import hashlib import rencode import lxml.etree import nacl.signing +import nacl.secret import nacl.exceptions +from usawa.error import VerifyError + AXX_ALL = 0xffffffff AXX_ANY = 0x01 @@ -44,6 +48,21 @@ class DID: return 'did:' + self.m + ':' + self.v +def key_from_export(v, passphrase='', did=None): + if passphrase == None: + passphrase = b'' + if isinstance(passphrase, str): + passphrase = passphrase.encode('utf-8') + h = hashlib.sha256() + h.update(passphrase) + z = h.digest() + o = nacl.secret.SecretBox(z) + r = None + try: + r = o.decrypt(v) + except nacl.exceptions.CryptoError: + raise VerifyError('decrypt fail') + return r class Wallet: """Wallet is an unimplemented class defining the interface for wallet operations. @@ -146,6 +165,29 @@ class Wallet: raise NotImplementedError + def export(self, passphrase=None): + if passphrase == None: + passphrase = b'' + elif isinstance(passphrase, str): + passphrase = passphrase.encode('utf-8') + if len(passphrase) == 0: + logg.warning('exporting key with no passphrase') + h = hashlib.sha256() + h.update(passphrase) + z = h.digest() + o = nacl.secret.SecretBox(z) + k = self.privkey() + r = o.encrypt(k) + if len(r) != len(k) + o.NONCE_SIZE + o.MACBYTES: + raise VerifyError() + return r + + + @staticmethod + def from_export(v, passphrase=None): + raise NotImplementedError() + + """Generate an identity XML tree entry from the wallet. The element generated is valid to be inserted as an identity sub-element in the ledger element. @@ -202,6 +244,8 @@ class DemoWallet(Wallet): publickey = nacl.signing.VerifyKey(publickey) self.pubk = publickey self.didval = DID(v=self.pubkey().hex()) + logg.debug('wallet created {}'.format(self.pubkey().hex())) + """Implements usawa.Wallet.sign """ @@ -236,6 +280,12 @@ class DemoWallet(Wallet): return r + @staticmethod + def from_export(v, passphrase=None): + k = key_from_export(v, passphrase=passphrase) + return DemoWallet(privatekey=k) + + class ACL: """ACL defines public keys to accept signatures from, and for which purpose. diff --git a/dummy/usawa/data/usawa.ini b/dummy/usawa/data/usawa.ini @@ -6,5 +6,6 @@ socket_file_path= [sigs] default_public_key= default_private_key = +key_passphrase = [fs_resolver] store_path= diff --git a/dummy/usawa/ledger.py b/dummy/usawa/ledger.py @@ -720,6 +720,15 @@ class Ledger: return self.cur + """Returns the serial of the latest entry added to the ledger. + + :return: Serial. + :rtype: int + """ + def current_serial(self): + return self.serial + + """Generate canonical XML for signature material. :return: Signature material. diff --git a/dummy/usawa/runnable/add.py b/dummy/usawa/runnable/add.py @@ -6,6 +6,7 @@ import argparse import uuid import datetime +import usawa.config from usawa import Ledger, Entry, EntryPart, DemoWallet, UnitIndex, load, Asset from usawa.constant import CATEGORIES from usawa.store import LedgerStore @@ -148,10 +149,12 @@ ledger_tree = load(arg.ledger_xml_file) uidx = UnitIndex.from_tree(ledger_tree) ledger = Ledger.from_tree(ledger_tree) +cfg = usawa.config.load() db = ValkeyStore('', host=ctx.valkey_host, port=ctx.valkey_port) store = LedgerStore(db, ledger) -pk = store.get_key() -wallet = DemoWallet(privatekey=pk) +#pk = store.get_key() +#wallet = DemoWallet(privatekey=pk) +wallet = store.get_key(DemoWallet, passphrase=cfg.get('SIGS_KEY_PASSPHRASE')) ledger.set_wallet(wallet) dt = datetime.datetime.now() diff --git a/dummy/usawa/runnable/create.py b/dummy/usawa/runnable/create.py @@ -7,6 +7,7 @@ import datetime import uuid from usawa import Ledger, DemoWallet, UnitIndex, ACL +import usawa.config from usawa.store import LedgerStore from whee.valkey import ValkeyStore @@ -142,12 +143,15 @@ pk = None wallet = None dt = datetime.datetime.now() +cfg = usawa.config.load() + try: - pk = store.get_key() + #pk = store.get_key() + wallet = store.get_key(DemoWallet, passphrase=cfg.get('SIGS_KEY_PASSPHRASE')) except FileNotFoundError: logg.info('no default key found') wallet = DemoWallet() - store.add_key(wallet) + store.add_key(wallet, passphrase=cfg.get('SIGS_KEY_PASSPHRASE')) if wallet == None: wallet = DemoWallet(privatekey=pk) logg.info('loaded existing key. {}'.format(wallet.pubkey().hex())) diff --git a/dummy/usawa/runnable/export.py b/dummy/usawa/runnable/export.py @@ -6,6 +6,7 @@ import argparse import uuid import datetime +import usawa.config from usawa import Ledger, Entry, EntryPart, DemoWallet, UnitIndex, load, ACL from usawa.constant import CATEGORIES from usawa.store import LedgerStore @@ -67,10 +68,12 @@ ctx = Context.from_args(arg) #ledger = Ledger.from_tree(ledger_tree) ledger = Ledger.from_file(arg.ledger_xml_file) +cfg = usawa.config.load() storedb = ValkeyStore('', host=ctx.valkey_host, port=ctx.valkey_port) store = LedgerStore(storedb, ledger) -pk = store.get_key() -wallet = DemoWallet(privatekey=pk) +#pk = store.get_key() +#wallet = DemoWallet(privatekey=pk) +wallet = store.get_key(DemoWallet, passphrase=cfg.get('SIGS_KEY_PASSPHRASE')) acl = ACL.from_wallet(wallet) store.load(acl=acl) resolve = FSResolver(ctx.output) diff --git a/dummy/usawa/runnable/import.py b/dummy/usawa/runnable/import.py @@ -6,6 +6,7 @@ import argparse import uuid import datetime +import usawa.config from usawa import Ledger, Entry, EntryPart, DemoWallet, load, ACL from usawa.constant import CATEGORIES from usawa.store import LedgerStore @@ -63,6 +64,7 @@ ctx = Context.from_args(arg) ledger = Ledger.from_file(arg.ledger_xml_file) +cfg = usawa.config.load() storedb = ValkeyStore('', host=ctx.valkey_host, port=ctx.valkey_port) store = LedgerStore(storedb, ledger) #pk = store.get_key() diff --git a/dummy/usawa/runnable/server.py b/dummy/usawa/runnable/server.py @@ -3,6 +3,7 @@ import argparse import signal #import threading +import usawa.config from usawa import Ledger, Entry, EntryPart, DemoWallet, ACL, UnitIndex, load from usawa.context import Context from usawa.service import Handler, UnixServer diff --git a/dummy/usawa/runnable/view.py b/dummy/usawa/runnable/view.py @@ -6,6 +6,7 @@ import argparse import uuid import datetime +import usawa.config from usawa import Ledger, Entry, EntryPart, DemoWallet, UnitIndex, load, ACL from usawa.constant import CATEGORIES from usawa.store import LedgerStore @@ -67,10 +68,12 @@ ctx = Context.from_args(arg) #ledger = Ledger.from_tree(ledger_tree) ledger = Ledger.from_file(arg.ledger_xml_file) +cfg = usawa.config.load() storedb = ValkeyStore('', host=ctx.valkey_host, port=ctx.valkey_port) store = LedgerStore(storedb, ledger) -pk = store.get_key() -wallet = DemoWallet(privatekey=pk) +#pk = store.get_key() +#wallet = DemoWallet(privatekey=pk) +wallet = store.get_key(DemoWallet, passphrase=cfg.get('SIGS_KEY_PASSPHRASE')) acl = ACL.from_wallet(wallet) store.load(acl=acl) sys.stdout.write(ledger.to_string()) diff --git a/dummy/usawa/service.py b/dummy/usawa/service.py @@ -1,6 +1,7 @@ import logging import socket import os +import threading from whee import Interface from usawa.store import LedgerStore @@ -241,14 +242,8 @@ class SocketServer: break if sckc != None: logg.info('connect: {}'.format(address)) - #th = threading.Thread(target=self.receive, args=(sckc, address)) - #th.start() - try: - self.receive(sckc, address) - except ConnectionResetError: - logg.warning('connection reset') - sckc.close() - break + th = threading.Thread(target=self.receive, args=(sckc, address)) + th.start() """Implements whee.Interface @@ -316,6 +311,7 @@ class SocketServer: v = b'\x01' logg.debug('harvest {}'.format(v.hex())) sckc.sendall(v) + sckc.close() def __str__(self): diff --git a/dummy/usawa/storage/ledger_repository.py b/dummy/usawa/storage/ledger_repository.py @@ -12,7 +12,7 @@ from usawa.storage.xml_utils import ( resolve_namespace, ) from usawa.asset import Asset -from usawa.crypto import ACL, DemoWallet +from usawa.crypto import ACL, DemoWallet, Wallet from usawa.error import VerifyError from usawa.ledger import Ledger from usawa.resolve.fs import FSResolver @@ -54,6 +54,8 @@ def sha256_verify(k, v=None): class LedgerRepository: """Repository that wraps LedgerStore and handles mapping""" + wallet_class = DemoWallet + def __init__( self, ledger_path=None, @@ -81,7 +83,7 @@ class LedgerRepository: self.resolver = FSResolver(self.cfg.get("FS_RESOLVER_STORE_PATH")) logg.info("Initailized wallet, pubkey: %s", self._wallet.pubkey().hex()) - def _init_store(self, write=False) -> tuple[LedgerStore, Ledger, DemoWallet]: + def _init_store(self, write=False) -> tuple[LedgerStore, Ledger, Wallet]: ledger_tree = load(self.ledger_path) ledger = Ledger.from_tree(ledger_tree) @@ -91,19 +93,38 @@ class LedgerRepository: logg.info("init store for write") self.store = LedgerStore(self.valkey_store, ledger) + if self._wallet is None: + # pk = self.store.get_key() + # if pk is None: + # raise ValueError("No private key found in store") + # self._wallet = DemoWallet(privatekey=pk) + self._wallet = self.store.get_key(wallet_class) else: logg.info("init store for read") self.store = LedgerStore(self.valkey_store, ledger) - try: - _ = self.store.get_key() - logg.info(f"Loaded wallet, pubkey: {self._wallet.pubkey().hex()[:16]}...") - except FileNotFoundError: - logg.warning("No private key found in store, initializing a a default one") - try: - self.store.add_key(wallet=self._wallet) - logg.info("Stored new private key successfully") - except Exception as e: - logg.warning(f"Could not store new key: {e}") + + if self._wallet is None: + try: + # pk = self.store.get_key() + # self._wallet = DemoWallet(privatekey=pk) + self._wallet = self.store.get_key(wallet_class) + logg.info( + f"Loaded wallet, pubkey: {self._wallet.pubkey().hex()[:16]}..." + ) + + except FileNotFoundError: + logg.warning( + "No private key found in store, initializing a a default one" + ) + privkey = bytes.fromhex(self.cfg.get("SIGS_DEFAULT_PRIVATE_KEY")) + self._wallet = DemoWallet(privatekey=privkey) + + # Add key to store + try: + self.store.add_key(wallet=self._wallet) + logg.info("Stored new private key successfully") + except Exception as e: + logg.warning(f"Could not store new key: {e}") logg.debug( "wallet pk: %s pubk: %s", diff --git a/dummy/usawa/store.py b/dummy/usawa/store.py @@ -210,11 +210,9 @@ class LedgerStore(Interface): return Asset.deserialize(v, digest) - """Flush ledger and load all entries from store. + """Load all entries from store, oldest to newest. - The existing state will always be lost. - - If the load fails, the ledger will be reset before returning. + Must be called on an unused ledger instance. Using with a ledger that contains or has contains entries is undefined. :raises FileNotFoundError: If an entry cannot be found. """ @@ -229,6 +227,25 @@ class LedgerStore(Interface): self.ledger.add_entry(o) + """Load all entries from store, newest to oldest. + + Must be called on an unused ledger instance. Using with a ledger that contains or has contains entries is undefined. + + :raises FileNotFoundError: If an entry cannot be found. + """ + def restore(self, until=0, acl=None): + logg.debug('restore ledger from store {}'.format(self.ledger)) + i = self.ledger.current_serial() + while i > until: + logg.debug('get entry serial {} ledger {}'.format(i, self.ledger)) + #try: + o = self.get_entry(i, acl=acl) + #except FileNotFoundError: + # break + self.ledger.add_entry(o, check_parent=False) + i -= 1 + + """Add signing key to the store. If this is the first key in the store, it will be set as default. @@ -239,10 +256,11 @@ class LedgerStore(Interface): :type acl: usawa.ACL :param default: If True, this key will be set as default key. :type default: bool - :todo: Currently the signing key is stored literally. It needs encryption! + :param passphrase: Passphrase to encrypt the key with. + :type passphrase: bytes :todo: Implement the ACL lookup """ - def add_key(self, wallet, acl=None, default=False): + def add_key(self, wallet, acl=None, default=False, passphrase=None): k = pfx_key() try: self.__o.get(k) @@ -252,24 +270,33 @@ class LedgerStore(Interface): if default: self.__o.put(k, pubkey, exist_ok=True) k = pfx_key(pubkey=pubkey) - self.__o.put(k, wallet.privkey()) + v = wallet.export(passphrase=passphrase) + self.__o.put(k, v) - """Get corresponding private key from the store. + """Get a newly instantiated wallet object from a private key in the store. If public key is not supplied, will retrieve the default private key. + :param wallet_class: Wallet class to use to instantiate a Wallet object from private key material. + :type: usawa.crypto.Wallet :param pubkey: Public key to retrieve private key for. :type pubkey: bytes - :return: Resulting key - :rtype: bytes + :param passphrase: Passphrase to decrypt the key with. + :type passphrase: bytes + :raises FileNotFoundError: No key exists. + :raises usawa.error.VerifyError: Key decryption failed. + :return: Resulting wallet + :rtype: usawa.crypto.Wallet """ - def get_key(self, pubkey=None): + def get_key(self, wallet_class, pubkey=None, passphrase=None): if pubkey == None: k = pfx_key() pubkey = self.__o.get(k) k = pfx_key(pubkey=pubkey) - return self.__o.get(k) + #return self.__o.get(k) + r = self.__o.get(k) + return wallet_class.from_export(r, passphrase=passphrase) """Implements whee.Interface.put