fixed broken random IP binding and Improved Onionr code safety
parent
8191854a2f
commit
57791c34a5
|
@ -19,6 +19,7 @@ core
|
||||||
.vscode/*
|
.vscode/*
|
||||||
venv/*
|
venv/*
|
||||||
onionr/fs*
|
onionr/fs*
|
||||||
|
onionr/tmp/*
|
||||||
|
|
||||||
*.dll
|
*.dll
|
||||||
*.exe
|
*.exe
|
||||||
|
@ -35,4 +36,4 @@ onionr/data/*.log
|
||||||
onionr-*.pkg.tar.gz
|
onionr-*.pkg.tar.gz
|
||||||
pkg/
|
pkg/
|
||||||
src/
|
src/
|
||||||
spawnnodes.py
|
spawnnodes.py
|
||||||
|
|
|
@ -110,7 +110,7 @@ Everyone is welcome to contribute. Help is wanted for the following:
|
||||||
* Creation of a shared lib for use from other languages and faster proof-of-work
|
* Creation of a shared lib for use from other languages and faster proof-of-work
|
||||||
* Android and IOS development
|
* Android and IOS development
|
||||||
* Windows and Mac support (already partially supported, testers needed)
|
* Windows and Mac support (already partially supported, testers needed)
|
||||||
* General bug fixes and development of new features
|
* Bug fixes and development of new features
|
||||||
* Testing
|
* Testing
|
||||||
* Translations/localizations
|
* Translations/localizations
|
||||||
* UI/UX design
|
* UI/UX design
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Set the user's locale for encoding reasons
|
# Set the user's locale for encoding reasons
|
||||||
import locale
|
import locale
|
||||||
locale.setlocale(locale.LC_ALL, '')
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
|
@ -118,7 +118,7 @@ def reload():
|
||||||
try:
|
try:
|
||||||
with open(get_config_file(), 'r', encoding="utf8") as configfile:
|
with open(get_config_file(), 'r', encoding="utf8") as configfile:
|
||||||
set_config(json.loads(configfile.read()))
|
set_config(json.loads(configfile.read()))
|
||||||
except:
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
pass
|
pass
|
||||||
#logger.debug('Failed to parse configuration file.')
|
#logger.debug('Failed to parse configuration file.')
|
||||||
|
|
||||||
|
|
|
@ -26,10 +26,11 @@ def get_expired_blocks():
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
date = int(epoch.get_epoch())
|
date = int(epoch.get_epoch())
|
||||||
|
|
||||||
execute = 'SELECT hash FROM hashes WHERE expire <= %s ORDER BY dateReceived;' % (date,)
|
compiled = (date,)
|
||||||
|
execute = 'SELECT hash FROM hashes WHERE expire <= ? ORDER BY dateReceived;'
|
||||||
|
|
||||||
rows = list()
|
rows = list()
|
||||||
for row in c.execute(execute):
|
for row in c.execute(execute, compiled):
|
||||||
for i in row:
|
for i in row:
|
||||||
rows.append(i)
|
rows.append(i)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
|
@ -34,12 +34,14 @@ def update_block_info(hash, key, data):
|
||||||
dateClaimed - timestamp claimed inside the block, only as trustworthy as the block author is
|
dateClaimed - timestamp claimed inside the block, only as trustworthy as the block author is
|
||||||
expire - expire date for a block
|
expire - expire date for a block
|
||||||
'''
|
'''
|
||||||
if key not in ('dateReceived', 'decrypted', 'dataType', 'dataFound', 'dataSaved', 'sig', 'author', 'dateClaimed', 'expire'):
|
if key not in ('dateReceived', 'decrypted', 'dataType', 'dataFound',
|
||||||
return False
|
'dataSaved', 'sig', 'author', 'dateClaimed', 'expire'):
|
||||||
|
raise ValueError('Key must be in the allowed list')
|
||||||
|
|
||||||
conn = sqlite3.connect(dbfiles.block_meta_db, timeout=30)
|
conn = sqlite3.connect(dbfiles.block_meta_db, timeout=30)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
args = (data, hash)
|
args = (data, hash)
|
||||||
|
# Unfortunately, not really possible
|
||||||
c.execute("UPDATE hashes SET " + key + " = ? where hash = ?;", args)
|
c.execute("UPDATE hashes SET " + key + " = ? where hash = ?;", args)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
|
@ -86,10 +86,7 @@ def clear_daemon_queue():
|
||||||
conn = sqlite3.connect(dbfiles.daemon_queue_db, timeout=30)
|
conn = sqlite3.connect(dbfiles.daemon_queue_db, timeout=30)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
try:
|
c.execute('DELETE FROM commands;')
|
||||||
c.execute('DELETE FROM commands;')
|
conn.commit()
|
||||||
conn.commit()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
|
@ -66,7 +66,7 @@ def set_address_info(address, key, data):
|
||||||
command = (data, address)
|
command = (data, address)
|
||||||
|
|
||||||
if key not in ('address', 'type', 'knownPeer', 'speed', 'success', 'failure', 'powValue', 'lastConnect', 'lastConnectAttempt', 'trust', 'introduced'):
|
if key not in ('address', 'type', 'knownPeer', 'speed', 'success', 'failure', 'powValue', 'lastConnect', 'lastConnectAttempt', 'trust', 'introduced'):
|
||||||
raise Exception("Got invalid database key when setting address info")
|
raise ValueError("Got invalid database key when setting address info, must be in whitelist")
|
||||||
else:
|
else:
|
||||||
c.execute('UPDATE adders SET ' + key + ' = ? WHERE address=?', command)
|
c.execute('UPDATE adders SET ' + key + ' = ? WHERE address=?', command)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
@ -61,9 +61,8 @@ def set_peer_info(peer, key, data):
|
||||||
|
|
||||||
command = (data, peer)
|
command = (data, peer)
|
||||||
|
|
||||||
# TODO: validate key on whitelist
|
|
||||||
if key not in ('id', 'name', 'pubkey', 'forwardKey', 'dateSeen', 'trust'):
|
if key not in ('id', 'name', 'pubkey', 'forwardKey', 'dateSeen', 'trust'):
|
||||||
raise Exception("Got invalid database key when setting peer info")
|
raise ValueError("Got invalid database key when setting peer info")
|
||||||
|
|
||||||
c.execute('UPDATE peers SET ' + key + ' = ? WHERE id=?', command)
|
c.execute('UPDATE peers SET ' + key + ' = ? WHERE id=?', command)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import json
|
import json
|
||||||
import onionrblockapi
|
import onionrblockapi
|
||||||
from onionrutils import bytesconverter, stringvalidators
|
from onionrutils import bytesconverter, stringvalidators
|
||||||
|
import onionrexceptions
|
||||||
class GetBlockData:
|
class GetBlockData:
|
||||||
def __init__(self, client_api_inst=None):
|
def __init__(self, client_api_inst=None):
|
||||||
return
|
return
|
||||||
|
|
||||||
def get_block_data(self, bHash, decrypt=False, raw=False, headerOnly=False):
|
def get_block_data(self, bHash, decrypt=False, raw=False, headerOnly=False):
|
||||||
assert stringvalidators.validate_hash(bHash)
|
if not stringvalidators.validate_hash(bHash): raise onionrexceptions.InvalidHexHash("block hash not valid hash format")
|
||||||
bl = onionrblockapi.Block(bHash)
|
bl = onionrblockapi.Block(bHash)
|
||||||
if decrypt:
|
if decrypt:
|
||||||
bl.decrypt()
|
bl.decrypt()
|
||||||
|
|
|
@ -1,11 +1,30 @@
|
||||||
import random, socket
|
import gevent
|
||||||
|
from gevent import socket, sleep
|
||||||
|
import secrets, random
|
||||||
import config, logger
|
import config, logger
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Hacky monkey patch so we can bind random localhosts without gevent trying to switch with an empty hub
|
||||||
|
socket.getfqdn = lambda n: n
|
||||||
|
|
||||||
|
def _get_acceptable_random_number()->int:
|
||||||
|
"""Return a cryptographically random number in the inclusive range (1, 255)"""
|
||||||
|
number = 0
|
||||||
|
while number == 0:
|
||||||
|
number = secrets.randbelow(0xFF)
|
||||||
|
return number
|
||||||
|
|
||||||
def set_bind_IP(filePath=''):
|
def set_bind_IP(filePath=''):
|
||||||
'''Set a random localhost IP to a specified file (intended for private or public API localhost IPs)'''
|
'''Set a random localhost IP to a specified file (intended for private or public API localhost IPs)'''
|
||||||
|
|
||||||
if config.get('general.random_bind_ip', True):
|
if config.get('general.random_bind_ip', True):
|
||||||
hostOctets = [str(127), str(random.randint(0x02, 0xFF)), str(random.randint(0x02, 0xFF)), str(random.randint(0x02, 0xFF))]
|
hostOctets = []
|
||||||
|
# Build the random localhost address
|
||||||
|
for i in range(3):
|
||||||
|
hostOctets.append(str(_get_acceptable_random_number()))
|
||||||
|
hostOctets = ['127'] + hostOctets
|
||||||
|
# Convert the localhost address to a normal string address
|
||||||
data = '.'.join(hostOctets)
|
data = '.'.join(hostOctets)
|
||||||
|
|
||||||
# Try to bind IP. Some platforms like Mac block non normal 127.x.x.x
|
# Try to bind IP. Some platforms like Mac block non normal 127.x.x.x
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -76,7 +76,7 @@ class PrivateEndpoints:
|
||||||
@private_endpoints_bp.route('/waitforshare/<name>', methods=['post'])
|
@private_endpoints_bp.route('/waitforshare/<name>', methods=['post'])
|
||||||
def waitforshare(name):
|
def waitforshare(name):
|
||||||
'''Used to prevent the **public** api from sharing blocks we just created'''
|
'''Used to prevent the **public** api from sharing blocks we just created'''
|
||||||
assert name.isalnum()
|
if not name.isalnum(): raise ValueError('block hash needs to be alpha numeric')
|
||||||
if name in client_api.publicAPI.hideBlocks:
|
if name in client_api.publicAPI.hideBlocks:
|
||||||
client_api.publicAPI.hideBlocks.remove(name)
|
client_api.publicAPI.hideBlocks.remove(name)
|
||||||
return Response("removed")
|
return Response("removed")
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
'''
|
'''
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
from flask import Blueprint, Response, request, abort
|
from flask import Blueprint, Response, request, abort
|
||||||
import onionrblockapi, onionrexceptions
|
import onionrblockapi, onionrexceptions
|
||||||
from onionrutils import stringvalidators
|
from onionrutils import stringvalidators
|
||||||
|
@ -37,7 +38,7 @@ def site(name):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
resp = base64.b64decode(resp)
|
resp = base64.b64decode(resp)
|
||||||
except:
|
except binascii.Error:
|
||||||
pass
|
pass
|
||||||
if resp == 'Not Found' or not resp:
|
if resp == 'Not Found' or not resp:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
|
@ -90,8 +90,8 @@ class Block:
|
||||||
# Check for replay attacks
|
# Check for replay attacks
|
||||||
try:
|
try:
|
||||||
if epoch.get_epoch() - blockmetadb.get_block_date(self.hash) > 60:
|
if epoch.get_epoch() - blockmetadb.get_block_date(self.hash) > 60:
|
||||||
assert cryptoutils.replay_validator(self.bmetadata['rply'])
|
if not cryptoutils.replay_validator(self.bmetadata['rply']): raise onionrexceptions.ReplayAttack
|
||||||
except (AssertionError, KeyError, TypeError) as e:
|
except (AssertionError, KeyError, TypeError, onionrexceptions.ReplayAttack) as e:
|
||||||
if not self.bypassReplayCheck:
|
if not self.bypassReplayCheck:
|
||||||
# Zero out variables to prevent reading of replays
|
# Zero out variables to prevent reading of replays
|
||||||
self.bmetadata = {}
|
self.bmetadata = {}
|
||||||
|
@ -101,7 +101,7 @@ class Block:
|
||||||
self.signature = ''
|
self.signature = ''
|
||||||
raise onionrexceptions.ReplayAttack('Signature is too old. possible replay attack')
|
raise onionrexceptions.ReplayAttack('Signature is too old. possible replay attack')
|
||||||
try:
|
try:
|
||||||
assert self.bmetadata['forwardEnc'] is True
|
if not self.bmetadata['forwardEnc']: raise KeyError
|
||||||
except (AssertionError, KeyError) as e:
|
except (AssertionError, KeyError) as e:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -120,7 +120,7 @@ def insert_block(data: Union[str, bytes], header: str ='txt',
|
||||||
|
|
||||||
# ensure expire is integer and of sane length
|
# ensure expire is integer and of sane length
|
||||||
if type(expire) is not type(None):
|
if type(expire) is not type(None):
|
||||||
assert len(str(int(expire))) < 20
|
if not len(str(int(expire))) < 20: raise ValueError('expire must be valid int less than 20 digits in length')
|
||||||
metadata['expire'] = expire
|
metadata['expire'] = expire
|
||||||
|
|
||||||
# send block data (and metadata) to POW module to get tokenized block data
|
# send block data (and metadata) to POW module to get tokenized block data
|
||||||
|
|
|
@ -32,8 +32,8 @@ def doExport(bHash):
|
||||||
def export_block():
|
def export_block():
|
||||||
exportDir = filepaths.export_location
|
exportDir = filepaths.export_location
|
||||||
try:
|
try:
|
||||||
assert stringvalidators.validate_hash(sys.argv[2])
|
if not stringvalidators.validate_hash(sys.argv[2]): raise ValueError
|
||||||
except (IndexError, AssertionError):
|
except (IndexError, ValueError):
|
||||||
logger.error('No valid block hash specified.', terminal=True)
|
logger.error('No valid block hash specified.', terminal=True)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -32,8 +32,8 @@ def add_ID():
|
||||||
key_manager = keymanager.KeyManager()
|
key_manager = keymanager.KeyManager()
|
||||||
try:
|
try:
|
||||||
sys.argv[2]
|
sys.argv[2]
|
||||||
assert sys.argv[2] == 'true'
|
if not sys.argv[2].lower() == 'true': raise ValueError
|
||||||
except (IndexError, AssertionError) as e:
|
except (IndexError, ValueError) as e:
|
||||||
newID = key_manager.addKey()[0]
|
newID = key_manager.addKey()[0]
|
||||||
else:
|
else:
|
||||||
logger.warn('Deterministic keys require random and long passphrases.', terminal=True)
|
logger.warn('Deterministic keys require random and long passphrases.', terminal=True)
|
||||||
|
|
|
@ -60,12 +60,11 @@ def getDifficultyForNewBlock(data, ourBlock=True):
|
||||||
|
|
||||||
return retData
|
return retData
|
||||||
|
|
||||||
def getHashDifficulty(h):
|
def getHashDifficulty(h: str):
|
||||||
'''
|
'''
|
||||||
Return the amount of leading zeroes in a hex hash string (h)
|
Return the amount of leading zeroes in a hex hash string (h)
|
||||||
'''
|
'''
|
||||||
difficulty = 0
|
difficulty = 0
|
||||||
assert type(h) is str
|
|
||||||
for character in h:
|
for character in h:
|
||||||
if character == '0':
|
if character == '0':
|
||||||
difficulty += 1
|
difficulty += 1
|
||||||
|
|
|
@ -37,7 +37,7 @@ class OnionrServices:
|
||||||
When a client wants to connect, contact their bootstrap address and tell them our
|
When a client wants to connect, contact their bootstrap address and tell them our
|
||||||
ephemeral address for our service by creating a new ConnectionServer instance
|
ephemeral address for our service by creating a new ConnectionServer instance
|
||||||
'''
|
'''
|
||||||
assert stringvalidators.validate_transport(address)
|
if not stringvalidators.validate_transport(address): raise ValueError('address must be valid')
|
||||||
BOOTSTRAP_TRIES = 10 # How many times to attempt contacting the bootstrap server
|
BOOTSTRAP_TRIES = 10 # How many times to attempt contacting the bootstrap server
|
||||||
TRY_WAIT = 3 # Seconds to wait before trying bootstrap again
|
TRY_WAIT = 3 # Seconds to wait before trying bootstrap again
|
||||||
# HTTP is fine because .onion/i2p is encrypted/authenticated
|
# HTTP is fine because .onion/i2p is encrypted/authenticated
|
||||||
|
|
|
@ -54,8 +54,8 @@ def bootstrap_client_service(peer, comm_inst=None, bootstrap_timeout=300):
|
||||||
|
|
||||||
http_server = WSGIServer(('127.0.0.1', bootstrap_port), bootstrap_app, log=None)
|
http_server = WSGIServer(('127.0.0.1', bootstrap_port), bootstrap_app, log=None)
|
||||||
try:
|
try:
|
||||||
assert comm_inst is not None
|
if comm_inst is None: raise ValueError
|
||||||
except (AttributeError, AssertionError) as e:
|
except (AttributeError, ValueError) as e:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
comm_inst.service_greenlets.append(http_server)
|
comm_inst.service_greenlets.append(http_server)
|
||||||
|
|
|
@ -58,10 +58,10 @@ def deleteBlock(blockHash):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def store(data, blockHash=''):
|
def store(data, blockHash=''):
|
||||||
assert stringvalidators.validate_hash(blockHash)
|
if not stringvalidators.validate_hash(blockHash): raise ValueError
|
||||||
ourHash = hashers.sha3_hash(data)
|
ourHash = hashers.sha3_hash(data)
|
||||||
if blockHash != '':
|
if blockHash != '':
|
||||||
assert ourHash == blockHash
|
if not ourHash == blockHash: raise ValueError('Hash specified does not meet internal hash check')
|
||||||
else:
|
else:
|
||||||
blockHash = ourHash
|
blockHash = ourHash
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ def store(data, blockHash=''):
|
||||||
blockFile.write(data)
|
blockFile.write(data)
|
||||||
|
|
||||||
def getData(bHash):
|
def getData(bHash):
|
||||||
assert stringvalidators.validate_hash(bHash)
|
if not stringvalidators.validate_hash(bHash): raise ValueError
|
||||||
|
|
||||||
bHash = bytesconverter.bytes_to_str(bHash)
|
bHash = bytesconverter.bytes_to_str(bHash)
|
||||||
|
|
||||||
|
|
|
@ -58,8 +58,9 @@ def process_block_metadata(blockHash: str):
|
||||||
# Set block expire time if specified
|
# Set block expire time if specified
|
||||||
try:
|
try:
|
||||||
expireTime = int(myBlock.getHeader('expire'))
|
expireTime = int(myBlock.getHeader('expire'))
|
||||||
assert len(str(expireTime)) < 20 # test that expire time is an integer of sane length (for epoch)
|
# test that expire time is an integer of sane length (for epoch)
|
||||||
except (AssertionError, ValueError, TypeError) as e:
|
if not len(str(expireTime)) < 20: raise ValueError('timestamp invalid')
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
expireTime = onionrvalues.DEFAULT_EXPIRE + curTime
|
expireTime = onionrvalues.DEFAULT_EXPIRE + curTime
|
||||||
finally:
|
finally:
|
||||||
expireTime = min(expireTime, curTime + onionrvalues.DEFAULT_EXPIRE)
|
expireTime = min(expireTime, curTime + onionrvalues.DEFAULT_EXPIRE)
|
||||||
|
|
|
@ -86,14 +86,14 @@ def validate_transport(id):
|
||||||
if peerType == 'i2p':
|
if peerType == 'i2p':
|
||||||
try:
|
try:
|
||||||
id.split('.b32.i2p')[2]
|
id.split('.b32.i2p')[2]
|
||||||
except:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
retVal = False
|
retVal = False
|
||||||
elif peerType == 'onion':
|
elif peerType == 'onion':
|
||||||
try:
|
try:
|
||||||
id.split('.onion')[2]
|
id.split('.onion')[2]
|
||||||
except:
|
except IndexError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
retVal = False
|
retVal = False
|
||||||
|
|
|
@ -66,14 +66,14 @@ def validate_metadata(metadata, block_data) -> bool:
|
||||||
break
|
break
|
||||||
elif i == 'expire':
|
elif i == 'expire':
|
||||||
try:
|
try:
|
||||||
assert int(metadata[i]) > epoch.get_epoch()
|
if not int(metadata[i]) > epoch.get_epoch(): raise ValueError
|
||||||
except AssertionError:
|
except ValueError:
|
||||||
logger.warn('Block is expired: %s less than %s' % (metadata[i], epoch.get_epoch()))
|
logger.warn('Block is expired: %s less than %s' % (metadata[i], epoch.get_epoch()))
|
||||||
break
|
break
|
||||||
elif i == 'encryptType':
|
elif i == 'encryptType':
|
||||||
try:
|
try:
|
||||||
assert metadata[i] in ('asym', 'sym', '')
|
if not metadata[i] in ('asym', 'sym', ''): raise ValueError
|
||||||
except AssertionError:
|
except ValueError:
|
||||||
logger.warn('Invalid encryption mode')
|
logger.warn('Invalid encryption mode')
|
||||||
break
|
break
|
||||||
elif i == 'sig':
|
elif i == 'sig':
|
||||||
|
|
|
@ -100,8 +100,8 @@ class PlainEncryption:
|
||||||
logger.info('Decrypted Message: \n\n%s' % data['data'], terminal=True)
|
logger.info('Decrypted Message: \n\n%s' % data['data'], terminal=True)
|
||||||
try:
|
try:
|
||||||
logger.info("Signing public key: %s" % (data['signer'],), terminal=True)
|
logger.info("Signing public key: %s" % (data['signer'],), terminal=True)
|
||||||
assert signing.ed_verify(data['data'], data['signer'], data['sig']) != False
|
if not signing.ed_verify(data['data'], data['signer'], data['sig']): raise ValueError
|
||||||
except (AssertionError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
logger.warn("WARNING: THIS MESSAGE HAS A MISSING OR INVALID SIGNATURE", terminal=True)
|
logger.warn("WARNING: THIS MESSAGE HAS A MISSING OR INVALID SIGNATURE", terminal=True)
|
||||||
else:
|
else:
|
||||||
logger.info("Message has good signature.", terminal=True)
|
logger.info("Message has good signature.", terminal=True)
|
||||||
|
|
Loading…
Reference in New Issue