├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGES.rst ├── LICENSE ├── README.rst ├── examples ├── test_client.py └── test_server.py ├── pytest.ini ├── requirements.txt ├── requirements ├── dev.txt ├── production.txt └── tests.txt ├── setup.cfg ├── setup.py └── ssb ├── __init__.py ├── feed ├── __init__.py └── models.py ├── muxrpc.py ├── packet_stream.py ├── tests ├── __init__.py ├── test_feed.py ├── test_packet_stream.py └── test_util.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .pytest_cache/ 4 | __pycache__ 5 | *.pyc 6 | node_modules 7 | .eggs 8 | *.egg-info 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - os: linux 5 | python: 3.5 6 | dist: xenial 7 | - os: linux 8 | python: 3.6 9 | dist: xenial 10 | - os: linux 11 | python: 3.7 12 | dist: xenial 13 | - os: linux 14 | python: 3.8-dev 15 | dist: xenial 16 | # shamelessly stolen from https://github.com/pyload/pyload 17 | - os: osx 18 | language: sh 19 | env: 20 | - HOMEBREW_NO_INSTALL_CLEANUP=1 21 | - HOMEBREW_NO_ANALYTICS=1 22 | before_cache: 23 | - rm -f "$HOME/Library/Caches/pip/log/debug.log" 24 | cache: 25 | directories: 26 | - "$HOME/Library/Caches/pip" 27 | addons: 28 | homebrew: 29 | packages: python3 30 | before_install: 31 | - python3 -m pip install --upgrade virtualenv 32 | - virtualenv -p python3 --system-site-packages "$HOME/venv" 33 | - source "$HOME/venv/bin/activate" 34 | install: 35 | - pip install .[tests] 36 | - pip install coveralls 37 | script: pytest 38 | after_success: 39 | - coveralls 40 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Main author: Pedro Ferreira 2 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pferreir/pyssb/975467030a6deeae6c5078ff10d90949e9adca56/CHANGES.rst -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 pyssb contributors (see AUTHORS for more details) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **WORK IN PROGRESS** 2 | 3 | pyssb - Secure Scuttlebutt in Python 4 | ==================================== 5 | 6 | |build-status| |code-coverage| 7 | 8 | Please, don't use this for anything that is not experimental. This is a first attempt at implementing the main 9 | functionality needed to run an SSB client/server. 10 | 11 | Things that are currently implemented: 12 | 13 | * Basic Message feed logic 14 | * Secret Handshake 15 | * packet-stream protocol 16 | 17 | Usage:: 18 | 19 | $ pip install -r requirements.txt 20 | 21 | Check the ``test_*.py`` files for basic examples. 22 | 23 | .. |build-status| image:: https://travis-ci.org/pferreir/pyssb.svg?branch=master 24 | :alt: Travis Build Status 25 | :target: https://travis-ci.org/pferreir/pyssb 26 | .. |code-coverage| image:: https://coveralls.io/repos/github/pferreir/pyssb/badge.svg?branch=master 27 | :alt: Code Coverage 28 | :target: https://coveralls.io/github/pferreir/pyssb?branch=master 29 | -------------------------------------------------------------------------------- /examples/test_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import time 4 | from asyncio import get_event_loop, gather, ensure_future 5 | 6 | from colorlog import ColoredFormatter 7 | 8 | from secret_handshake.network import SHSClient 9 | from ssb.muxrpc import MuxRPCAPI, MuxRPCAPIException 10 | from ssb.packet_stream import PacketStream, PSMessageType 11 | from ssb.util import load_ssb_secret 12 | 13 | import hashlib 14 | import base64 15 | 16 | 17 | api = MuxRPCAPI() 18 | 19 | 20 | @api.define('createHistoryStream') 21 | def create_history_stream(connection, msg): 22 | print('create_history_stream', msg) 23 | # msg = PSMessage(PSMessageType.JSON, True, stream=True, end_err=True, req=-req) 24 | # connection.write(msg) 25 | 26 | 27 | @api.define('blobs.createWants') 28 | def create_wants(connection, msg): 29 | print('create_wants', msg) 30 | 31 | 32 | async def test_client(): 33 | async for msg in api.call('createHistoryStream', [{ 34 | 'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", 35 | 'seq': 1, 36 | 'live': False, 37 | 'keys': False 38 | }], 'source'): 39 | print('> RESPONSE:', msg) 40 | 41 | try: 42 | print('> RESPONSE:', await api.call('whoami', [], 'sync')) 43 | except MuxRPCAPIException as e: 44 | print(e) 45 | 46 | handler = api.call('gossip.ping', [], 'duplex') 47 | handler.send(struct.pack('l', int(time.time() * 1000)), msg_type=PSMessageType.BUFFER) 48 | async for msg in handler: 49 | print('> RESPONSE:', msg) 50 | handler.send(True, end=True) 51 | break 52 | 53 | img_data = b'' 54 | async for msg in api.call('blobs.get', ['&kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM=.sha256'], 'source'): 55 | if msg.type.name == 'BUFFER': 56 | img_data += msg.data 57 | if msg.type.name == 'JSON' and msg.data == b'true': 58 | assert (base64.b64encode(hashlib.sha256(img_data).digest()) == 59 | b'kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM=') 60 | with open('./ub1k.jpg', 'wb') as f: 61 | f.write(img_data) 62 | 63 | 64 | async def main(): 65 | client = SHSClient('127.0.0.1', 8008, keypair, bytes(keypair.verify_key)) 66 | packet_stream = PacketStream(client) 67 | await client.open() 68 | api.add_connection(packet_stream) 69 | await gather(ensure_future(api), test_client()) 70 | 71 | 72 | if __name__ == '__main__': 73 | # create console handler and set level to debug 74 | ch = logging.StreamHandler() 75 | ch.setLevel(logging.INFO) 76 | 77 | # create formatter 78 | formatter = ColoredFormatter('%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - ' 79 | '%(cyan)s%(message)s%(reset)s') 80 | 81 | # add formatter to ch 82 | ch.setFormatter(formatter) 83 | 84 | # add ch to logger 85 | logger = logging.getLogger('packet_stream') 86 | logger.setLevel(logging.INFO) 87 | logger.addHandler(ch) 88 | 89 | keypair = load_ssb_secret()['keypair'] 90 | 91 | loop = get_event_loop() 92 | loop.run_until_complete(main()) 93 | loop.close() 94 | -------------------------------------------------------------------------------- /examples/test_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from asyncio import gather, get_event_loop, ensure_future 3 | 4 | from colorlog import ColoredFormatter 5 | 6 | from secret_handshake import SHSServer 7 | from ssb.packet_stream import PacketStream 8 | from ssb.muxrpc import MuxRPCAPI 9 | from ssb.util import load_ssb_secret 10 | 11 | api = MuxRPCAPI() 12 | 13 | 14 | async def on_connect(conn): 15 | packet_stream = PacketStream(conn) 16 | api.add_connection(packet_stream) 17 | 18 | print('connect', conn) 19 | async for msg in packet_stream: 20 | print(msg) 21 | 22 | 23 | async def main(): 24 | server = SHSServer('127.0.0.1', 8008, load_ssb_secret()['keypair']) 25 | server.on_connect(on_connect) 26 | await server.listen() 27 | 28 | 29 | if __name__ == '__main__': 30 | # create console handler and set level to debug 31 | ch = logging.StreamHandler() 32 | ch.setLevel(logging.DEBUG) 33 | 34 | # create formatter 35 | formatter = ColoredFormatter('%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - ' 36 | '%(cyan)s%(message)s%(reset)s') 37 | 38 | # add formatter to ch 39 | ch.setFormatter(formatter) 40 | 41 | # add ch to logger 42 | logger = logging.getLogger('packet_stream') 43 | logger.setLevel(logging.DEBUG) 44 | logger.addHandler(ch) 45 | 46 | loop = get_event_loop() 47 | loop.run_until_complete(main()) 48 | loop.run_forever() 49 | loop.close() 50 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov ssb --cov-report term-missing --no-cov-on-fail 3 | python_files = ssb/tests/test_*.py 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/production.txt 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r tests.txt 2 | flake8 3 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | colorlog 2 | pynacl>=1.1.2 3 | pyyaml>=4.2b1 4 | secret-handshake 5 | simplejson 6 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | -r production.txt 2 | asynctest 3 | coverage 4 | pytest 5 | pytest-asyncio 6 | pytest-cov 7 | pytest-mock 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 PySecretHandshake contributors (see AUTHORS for more details) 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | """A module that implements Secret Handshake as specified in "Designing a Secret Handshake: Authenticated 22 | Key Exchange as a Capability System" (Dominic Tarr, 2015).""" 23 | 24 | from setuptools import find_packages, setup 25 | 26 | readme = open('README.rst').read() 27 | history = open('CHANGES.rst').read() 28 | 29 | tests_require = [ 30 | 'check-manifest>=0.39', 31 | 'coverage>=4.5.3', 32 | 'isort>=4.3.20', 33 | 'pep257>=0.7.0', 34 | 'pytest-cov>=2.7.1', 35 | 'pytest>=4.6.3', 36 | 'pytest-asyncio==0.10.0', 37 | 'asynctest==0.13.0', 38 | 'pytest-mock==1.10.4' 39 | ] 40 | 41 | extras_require = { 42 | 'docs': [ 43 | 'Sphinx>=2.1.1', 44 | ], 45 | 'tests': tests_require, 46 | } 47 | extras_require['all'] = sum((lst for lst in extras_require.values()), []) 48 | 49 | install_requires = [ 50 | 'async-generator==1.10', 51 | 'pynacl==1.3.0', 52 | 'pyyaml>=4.2b1', 53 | 'secret-handshake', 54 | 'simplejson==3.16.0' 55 | ] 56 | 57 | setup_requires = [ 58 | 'pytest-runner' 59 | ] 60 | 61 | packages = find_packages() 62 | 63 | setup( 64 | name='ssb', 65 | version='0.1.0.dev3', 66 | description=__doc__, 67 | long_description=(readme + '\n\n' + history), 68 | license='MIT', 69 | author='PyScuttleButt Contributors', 70 | author_email='pedro@dete.st', 71 | url='https://github.com/pferreir/PyScuttlebutt', 72 | packages=packages, 73 | include_package_data=True, 74 | extras_require=extras_require, 75 | install_requires=install_requires, 76 | setup_requires=setup_requires, 77 | tests_require=tests_require, 78 | zip_safe=False, 79 | classifiers=[ 80 | 'Intended Audience :: Developers', 81 | 'License :: OSI Approved :: MIT License', 82 | 'Operating System :: OS Independent', 83 | 'Programming Language :: Python', 84 | 'Topic :: Software Development :: Libraries :: Python Modules', 85 | 'Programming Language :: Python :: 3.5', 86 | 'Programming Language :: Python :: 3.6' 87 | ], 88 | ) 89 | -------------------------------------------------------------------------------- /ssb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pferreir/pyssb/975467030a6deeae6c5078ff10d90949e9adca56/ssb/__init__.py -------------------------------------------------------------------------------- /ssb/feed/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import Feed, LocalFeed, Message, LocalMessage, NoPrivateKeyException 2 | 3 | __all__ = ('Feed', 'LocalFeed', 'Message', 'LocalMessage', 'NoPrivateKeyException') 4 | -------------------------------------------------------------------------------- /ssb/feed/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from base64 import b64encode 3 | from collections import namedtuple, OrderedDict 4 | from hashlib import sha256 5 | 6 | from simplejson import dumps, loads 7 | 8 | from ssb.util import tag 9 | 10 | 11 | OrderedMsg = namedtuple('OrderedMsg', ('previous', 'author', 'sequence', 'timestamp', 'hash', 'content')) 12 | 13 | 14 | class NoPrivateKeyException(Exception): 15 | pass 16 | 17 | 18 | def to_ordered(data): 19 | smsg = OrderedMsg(**data) 20 | return OrderedDict((k, getattr(smsg, k)) for k in smsg._fields) 21 | 22 | 23 | def get_millis_1970(): 24 | return int(datetime.datetime.utcnow().timestamp() * 1000) 25 | 26 | 27 | class Feed(object): 28 | def __init__(self, public_key): 29 | self.public_key = public_key 30 | 31 | @property 32 | def id(self): 33 | return tag(self.public_key).decode('ascii') 34 | 35 | def sign(self, msg): 36 | raise NoPrivateKeyException('Cannot use remote identity to sign (no private key!)') 37 | 38 | 39 | class LocalFeed(Feed): 40 | def __init__(self, private_key): 41 | self.private_key = private_key 42 | 43 | @property 44 | def public_key(self): 45 | return self.private_key.verify_key 46 | 47 | def sign(self, msg): 48 | return self.private_key.sign(msg).signature 49 | 50 | 51 | class Message(object): 52 | def __init__(self, feed, content, signature, sequence=1, timestamp=None, previous=None): 53 | self.feed = feed 54 | self.content = content 55 | 56 | if signature is None: 57 | raise ValueError("signature can't be None") 58 | self.signature = signature 59 | 60 | self.previous = previous 61 | if self.previous: 62 | self.sequence = self.previous.sequence + 1 63 | else: 64 | self.sequence = sequence 65 | 66 | self.timestamp = get_millis_1970() if timestamp is None else timestamp 67 | 68 | @classmethod 69 | def parse(cls, data, feed): 70 | obj = loads(data, object_pairs_hook=OrderedDict) 71 | msg = cls(feed, obj['content'], timestamp=obj['timestamp']) 72 | return msg 73 | 74 | def serialize(self, add_signature=True): 75 | return dumps(self.to_dict(add_signature=add_signature), indent=2).encode('utf-8') 76 | 77 | def to_dict(self, add_signature=True): 78 | obj = to_ordered({ 79 | 'previous': self.previous.key if self.previous else None, 80 | 'author': self.feed.id, 81 | 'sequence': self.sequence, 82 | 'timestamp': self.timestamp, 83 | 'hash': 'sha256', 84 | 'content': self.content 85 | }) 86 | 87 | if add_signature: 88 | obj['signature'] = self.signature 89 | return obj 90 | 91 | def verify(self, signature): 92 | return self.signature == signature 93 | 94 | @property 95 | def hash(self): 96 | hash = sha256(self.serialize()).digest() 97 | return b64encode(hash).decode('ascii') + '.sha256' 98 | 99 | @property 100 | def key(self): 101 | return '%' + self.hash 102 | 103 | 104 | class LocalMessage(Message): 105 | def __init__(self, feed, content, signature=None, sequence=1, timestamp=None, previous=None): 106 | self.feed = feed 107 | self.content = content 108 | 109 | self.previous = previous 110 | if self.previous: 111 | self.sequence = self.previous.sequence + 1 112 | else: 113 | self.sequence = sequence 114 | 115 | self.timestamp = get_millis_1970() if timestamp is None else timestamp 116 | 117 | if signature is None: 118 | self.signature = self._sign() 119 | else: 120 | self.signature = signature 121 | 122 | def _sign(self): 123 | # ensure ordering of keys and indentation of 2 characters, like ssb-keys 124 | data = self.serialize(add_signature=False) 125 | return (b64encode(bytes(self.feed.sign(data))) + b'.sig.ed25519').decode('ascii') 126 | -------------------------------------------------------------------------------- /ssb/muxrpc.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from async_generator import async_generator, yield_ 4 | 5 | from ssb.packet_stream import PSMessageType 6 | 7 | 8 | class MuxRPCAPIException(Exception): 9 | pass 10 | 11 | 12 | class MuxRPCHandler(object): 13 | def check_message(self, msg): 14 | body = msg.body 15 | if isinstance(body, dict) and 'name' in body and body['name'] == 'Error': 16 | raise MuxRPCAPIException(body['message']) 17 | 18 | 19 | class MuxRPCRequestHandler(MuxRPCHandler): 20 | def __init__(self, ps_handler): 21 | self.ps_handler = ps_handler 22 | 23 | def __await__(self): 24 | msg = (yield from self.ps_handler.__await__()) 25 | self.check_message(msg) 26 | return msg 27 | 28 | 29 | class MuxRPCSourceHandler(MuxRPCHandler): 30 | def __init__(self, ps_handler): 31 | self.ps_handler = ps_handler 32 | 33 | @async_generator 34 | async def __aiter__(self): 35 | async for msg in self.ps_handler: 36 | try: 37 | self.check_message(msg) 38 | await yield_(msg) 39 | except MuxRPCAPIException: 40 | raise 41 | 42 | 43 | class MuxRPCSinkHandlerMixin(object): 44 | 45 | def send(self, msg, msg_type=PSMessageType.JSON, end=False): 46 | self.connection.send(msg, stream=True, msg_type=msg_type, req=self.req, end_err=end) 47 | 48 | 49 | class MuxRPCDuplexHandler(MuxRPCSinkHandlerMixin, MuxRPCSourceHandler): 50 | def __init__(self, ps_handler, connection, req): 51 | super(MuxRPCDuplexHandler, self).__init__(ps_handler) 52 | self.connection = connection 53 | self.req = req 54 | 55 | 56 | class MuxRPCSinkHandler(MuxRPCHandler, MuxRPCSinkHandlerMixin): 57 | def __init__(self, connection, req): 58 | self.connection = connection 59 | self.req = req 60 | 61 | 62 | def _get_appropriate_api_handler(type_, connection, ps_handler, req): 63 | if type_ in {'sync', 'async'}: 64 | return MuxRPCRequestHandler(ps_handler) 65 | elif type_ == 'source': 66 | return MuxRPCSourceHandler(ps_handler) 67 | elif type_ == 'sink': 68 | return MuxRPCSinkHandler(connection, req) 69 | elif type_ == 'duplex': 70 | return MuxRPCDuplexHandler(ps_handler, connection, req) 71 | 72 | 73 | class MuxRPCRequest(object): 74 | @classmethod 75 | def from_message(cls, message): 76 | body = message.body 77 | return cls('.'.join(body['name']), body['args']) 78 | 79 | def __init__(self, name, args): 80 | self.name = name 81 | self.args = args 82 | 83 | def __repr__(self): 84 | return ''.format(self) 85 | 86 | 87 | class MuxRPCMessage(object): 88 | @classmethod 89 | def from_message(cls, message): 90 | return cls(message.body) 91 | 92 | def __init__(self, body): 93 | self.body = body 94 | 95 | def __repr__(self): 96 | return ''.format(self) 97 | 98 | 99 | class MuxRPCAPI(object): 100 | def __init__(self): 101 | self.handlers = {} 102 | self.connection = None 103 | 104 | async def __await__(self): 105 | async for req_message in self.connection: 106 | body = req_message.body 107 | if req_message is None: 108 | return 109 | if isinstance(body, dict) and body.get('name'): 110 | self.process(self.connection, MuxRPCRequest.from_message(req_message)) 111 | 112 | def add_connection(self, connection): 113 | self.connection = connection 114 | 115 | def define(self, name): 116 | def _handle(f): 117 | self.handlers[name] = f 118 | 119 | @wraps(f) 120 | def _f(*args, **kwargs): 121 | return f(*args, **kwargs) 122 | return f 123 | return _handle 124 | 125 | def process(self, connection, request): 126 | handler = self.handlers.get(request.name) 127 | if not handler: 128 | raise MuxRPCAPIException('Method {} not found!'.format(request.name)) 129 | handler(connection, request) 130 | 131 | def call(self, name, args, type_='sync'): 132 | if not self.connection.is_connected: 133 | raise Exception('not connected') 134 | old_counter = self.connection.req_counter 135 | ps_handler = self.connection.send({ 136 | 'name': name.split('.'), 137 | 'args': args, 138 | 'type': type_ 139 | }, stream=type_ in {'sink', 'source', 'duplex'}) 140 | return _get_appropriate_api_handler(type_, self.connection, ps_handler, old_counter) 141 | -------------------------------------------------------------------------------- /ssb/packet_stream.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | from asyncio import Event, Queue 4 | from enum import Enum 5 | from time import time 6 | from math import ceil 7 | 8 | import simplejson 9 | from async_generator import async_generator, yield_ 10 | 11 | from secret_handshake import SHSClient, SHSServer 12 | 13 | 14 | logger = logging.getLogger('packet_stream') 15 | 16 | 17 | class PSMessageType(Enum): 18 | BUFFER = 0 19 | TEXT = 1 20 | JSON = 2 21 | 22 | 23 | class PSStreamHandler(object): 24 | def __init__(self, req): 25 | super(PSStreamHandler).__init__() 26 | self.req = req 27 | self.queue = Queue() 28 | 29 | async def process(self, msg): 30 | await self.queue.put(msg) 31 | 32 | async def stop(self): 33 | await self.queue.put(None) 34 | 35 | @async_generator 36 | async def __aiter__(self): 37 | while True: 38 | elem = await self.queue.get() 39 | if not elem: 40 | return 41 | await yield_(elem) 42 | 43 | 44 | class PSRequestHandler(object): 45 | def __init__(self, req): 46 | super(PSRequestHandler).__init__() 47 | self.req = req 48 | self.event = Event() 49 | self._msg = None 50 | 51 | async def process(self, msg): 52 | self._msg = msg 53 | self.event.set() 54 | 55 | async def stop(self): 56 | if not self.event.is_set(): 57 | self.event.set() 58 | 59 | def __await__(self): 60 | # wait until 'process' is called 61 | yield from self.event.wait().__await__() 62 | return self._msg 63 | 64 | 65 | class PSMessage(object): 66 | 67 | @classmethod 68 | def from_header_body(cls, flags, req, body): 69 | type_ = PSMessageType(flags & 0x03) 70 | 71 | if type_ == PSMessageType.TEXT: 72 | body = body.decode('utf-8') 73 | elif type_ == PSMessageType.JSON: 74 | body = simplejson.loads(body) 75 | 76 | return cls(type_, body, bool(flags & 0x08), bool(flags & 0x04), req=req) 77 | 78 | @property 79 | def data(self): 80 | if self.type == PSMessageType.TEXT: 81 | return self.body.encode('utf-8') 82 | elif self.type == PSMessageType.JSON: 83 | return simplejson.dumps(self.body).encode('utf-8') 84 | return self.body 85 | 86 | def __init__(self, type_, body, stream, end_err, req=None): 87 | self.stream = stream 88 | self.end_err = end_err 89 | self.type = type_ 90 | self.body = body 91 | self.req = req 92 | 93 | def __repr__(self): 94 | if self.type == PSMessageType.BUFFER: 95 | body = '{} bytes'.format(len(self.body)) 96 | else: 97 | body = self.body 98 | return ''.format(self.type.name, body, 99 | '' if self.req is None else ' [{}]'.format(self.req), 100 | '~' if self.stream else '', '!' if self.end_err else '') 101 | 102 | 103 | class PacketStream(object): 104 | def __init__(self, connection): 105 | self.connection = connection 106 | self.req_counter = 1 107 | self._event_map = {} 108 | 109 | def register_handler(self, handler): 110 | self._event_map[handler.req] = (time(), handler) 111 | 112 | @property 113 | def is_connected(self): 114 | return self.connection.is_connected 115 | 116 | @async_generator 117 | async def __aiter__(self): 118 | while True: 119 | msg = await self.read() 120 | if not msg: 121 | return 122 | # filter out replies 123 | if msg.req >= 0: 124 | await yield_(msg) 125 | 126 | async def __await__(self): 127 | async for data in self: 128 | logger.info('RECV: %r', data) 129 | if data is None: 130 | return 131 | 132 | async def _read(self): 133 | try: 134 | header = await self.connection.read() 135 | if not header or header == b'\x00' * 9: 136 | return 137 | flags, length, req = struct.unpack('>BIi', header) 138 | 139 | n_packets = ceil(length / 4096) 140 | 141 | body = b'' 142 | for n in range(n_packets): 143 | body += await self.connection.read() 144 | 145 | logger.debug('READ %s %s', header, len(body)) 146 | return PSMessage.from_header_body(flags, req, body) 147 | except StopAsyncIteration: 148 | logger.debug('DISCONNECT') 149 | self.connection.disconnect() 150 | return None 151 | 152 | async def read(self): 153 | msg = await self._read() 154 | if not msg: 155 | return None 156 | # check whether it's a reply and handle accordingly 157 | if msg.req < 0: 158 | t, handler = self._event_map[-msg.req] 159 | await handler.process(msg) 160 | logger.info('RESPONSE [%d]: %r', -msg.req, msg) 161 | if msg.end_err: 162 | await handler.stop() 163 | del self._event_map[-msg.req] 164 | logger.info('RESPONSE [%d]: EOS', -msg.req) 165 | return msg 166 | 167 | def _write(self, msg): 168 | logger.info('SEND [%d]: %r', msg.req, msg) 169 | header = struct.pack('>BIi', (int(msg.stream) << 3) | (int(msg.end_err) << 2) | msg.type.value, len(msg.data), 170 | msg.req) 171 | self.connection.write(header) 172 | self.connection.write(msg.data) 173 | logger.debug('WRITE HDR: %s', header) 174 | logger.debug('WRITE DATA: %s', msg.data) 175 | 176 | def send(self, data, msg_type=PSMessageType.JSON, stream=False, end_err=False, req=None): 177 | update_counter = False 178 | if req is None: 179 | update_counter = True 180 | req = self.req_counter 181 | 182 | msg = PSMessage(msg_type, data, stream=stream, end_err=end_err, req=req) 183 | 184 | # send request 185 | self._write(msg) 186 | 187 | if stream: 188 | handler = PSStreamHandler(self.req_counter) 189 | else: 190 | handler = PSRequestHandler(self.req_counter) 191 | self.register_handler(handler) 192 | 193 | if update_counter: 194 | self.req_counter += 1 195 | return handler 196 | 197 | def disconnect(self): 198 | self._connected = False 199 | self.connection.disconnect() 200 | -------------------------------------------------------------------------------- /ssb/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pferreir/pyssb/975467030a6deeae6c5078ff10d90949e9adca56/ssb/tests/__init__.py -------------------------------------------------------------------------------- /ssb/tests/test_feed.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from collections import OrderedDict 3 | 4 | import pytest 5 | from nacl.signing import SigningKey, VerifyKey 6 | 7 | from ssb.feed import LocalMessage, LocalFeed, Feed, Message, NoPrivateKeyException 8 | 9 | 10 | SERIALIZED_M1 = b"""{ 11 | "previous": null, 12 | "author": "@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519", 13 | "sequence": 1, 14 | "timestamp": 1495706260190, 15 | "hash": "sha256", 16 | "content": { 17 | "type": "about", 18 | "about": "@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519", 19 | "name": "neo", 20 | "description": "The Chosen One" 21 | }, 22 | "signature": "lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519" 23 | }""" 24 | 25 | 26 | @pytest.fixture() 27 | def local_feed(): 28 | secret = b64decode('Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=') 29 | return LocalFeed(SigningKey(secret)) 30 | 31 | 32 | @pytest.fixture() 33 | def remote_feed(): 34 | public = b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=') 35 | return Feed(VerifyKey(public)) 36 | 37 | 38 | def test_local_feed(): 39 | secret = b64decode('Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=') 40 | feed = LocalFeed(SigningKey(secret)) 41 | assert bytes(feed.private_key) == secret 42 | assert bytes(feed.public_key) == b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=') 43 | assert feed.id == '@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519' 44 | 45 | 46 | def test_remote_feed(): 47 | public = b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=') 48 | feed = Feed(VerifyKey(public)) 49 | assert bytes(feed.public_key) == public 50 | assert feed.id == '@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519' 51 | 52 | m1 = Message(feed, OrderedDict([ 53 | ('type', 'about'), 54 | ('about', feed.id), 55 | ('name', 'neo'), 56 | ('description', 'The Chosen One') 57 | ]), 'foo', timestamp=1495706260190) 58 | 59 | with pytest.raises(NoPrivateKeyException): 60 | feed.sign(m1) 61 | 62 | 63 | def test_local_message(local_feed): 64 | m1 = LocalMessage(local_feed, OrderedDict([ 65 | ('type', 'about'), 66 | ('about', local_feed.id), 67 | ('name', 'neo'), 68 | ('description', 'The Chosen One') 69 | ]), timestamp=1495706260190) 70 | assert m1.timestamp == 1495706260190 71 | assert m1.previous is None 72 | assert m1.sequence == 1 73 | assert m1.signature == \ 74 | 'lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519' 75 | assert m1.key == '%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256' 76 | 77 | m2 = LocalMessage(local_feed, OrderedDict([ 78 | ('type', 'about'), 79 | ('about', local_feed.id), 80 | ('name', 'morpheus'), 81 | ('description', 'Dude with big jaw') 82 | ]), previous=m1, timestamp=1495706447426) 83 | assert m2.timestamp == 1495706447426 84 | assert m2.previous is m1 85 | assert m2.sequence == 2 86 | assert m2.signature == \ 87 | '3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519' 88 | assert m2.key == '%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256' 89 | 90 | 91 | def test_remote_message(remote_feed): 92 | signature = 'lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519' 93 | m1 = Message(remote_feed, OrderedDict([ 94 | ('type', 'about'), 95 | ('about', remote_feed.id), 96 | ('name', 'neo'), 97 | ('description', 'The Chosen One') 98 | ]), signature, timestamp=1495706260190) 99 | assert m1.timestamp == 1495706260190 100 | assert m1.previous is None 101 | assert m1.sequence == 1 102 | assert m1.signature == signature 103 | assert m1.key == '%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256' 104 | 105 | signature = '3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519' 106 | m2 = Message(remote_feed, OrderedDict([ 107 | ('type', 'about'), 108 | ('about', remote_feed.id), 109 | ('name', 'morpheus'), 110 | ('description', 'Dude with big jaw') 111 | ]), signature, previous=m1, timestamp=1495706447426) 112 | assert m2.timestamp == 1495706447426 113 | assert m2.previous is m1 114 | assert m2.sequence == 2 115 | assert m2.signature == signature 116 | m2.verify(signature) 117 | assert m2.key == '%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256' 118 | 119 | 120 | def test_remote_no_signature(remote_feed): 121 | with pytest.raises(ValueError): 122 | Message(remote_feed, OrderedDict([ 123 | ('type', 'about'), 124 | ('about', remote_feed.id), 125 | ('name', 'neo'), 126 | ('description', 'The Chosen One') 127 | ]), None, timestamp=1495706260190) 128 | 129 | 130 | def test_serialize(local_feed): 131 | m1 = LocalMessage(local_feed, OrderedDict([ 132 | ('type', 'about'), 133 | ('about', local_feed.id), 134 | ('name', 'neo'), 135 | ('description', 'The Chosen One') 136 | ]), timestamp=1495706260190) 137 | 138 | assert m1.serialize() == SERIALIZED_M1 139 | 140 | 141 | def test_parse(local_feed): 142 | m1 = LocalMessage.parse(SERIALIZED_M1, local_feed) 143 | assert m1.content == { 144 | 'type': 'about', 145 | 'about': local_feed.id, 146 | 'name': 'neo', 147 | 'description': 'The Chosen One' 148 | } 149 | assert m1.timestamp == 1495706260190 150 | -------------------------------------------------------------------------------- /ssb/tests/test_packet_stream.py: -------------------------------------------------------------------------------- 1 | import json 2 | from asyncio import ensure_future, gather, Event 3 | 4 | import pytest 5 | from asynctest import patch 6 | from nacl.signing import SigningKey 7 | 8 | from secret_handshake.network import SHSDuplexStream 9 | from ssb.packet_stream import PacketStream, PSMessageType 10 | 11 | 12 | async def _collect_messages(generator): 13 | results = [] 14 | async for msg in generator: 15 | results.append(msg) 16 | return results 17 | 18 | MSG_BODY_1 = (b'{"previous":"%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA' 19 | b'VvNNVH4F2BSxwhYmx8=.ed25519","sequence":116,"timestamp":1496696699331,"hash":"sha256","content":{"type"' 20 | b':"post","channel":"crypto","text":"Does anybody know any good resources (e.g. books) to learn cryptogra' 21 | b'phy? I\'m not speaking of basic concepts (e.g. what\'s a private key) but the actual mathematics behind' 22 | b' the whole thing.\\nI have a copy of the \\"Handbook of Applied Cryptography\\" on my bookshelf but I f' 23 | b'ound it too long/hard to follow. Are there any better alternatives?","mentions":[]},"signature":"hqKePb' 24 | b'bTXWxEi1njDnOWFsL0M0AoNoWyBFgNE6KXj//DThepaZSy9vRbygDHX5uNmCdyOrsQrwZsZhmUYKwtDQ==.sig.ed25519"}') 25 | 26 | MSG_BODY_2 = (b'{"previous":"%iQRhPyqmNLpGaO1Tpm1I22jqnUEwRwkCTDbwAGtM+lY=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA' 27 | b'VvNNVH4F2BSxwhYmx8=.ed25519","sequence":103,"timestamp":1496674211806,"hash":"sha256","content":{"type"' 28 | b':"post","channel":"git-ssb","text":"Is it only me or `git.scuttlebot.io` is timing out?\\n\\nE.g. try a' 29 | b'ccessing %vZCTqraoqKBKNZeATErXEtnoEr+wnT3p8tT+vL+29I4=.sha256","mentions":[{"link":"%vZCTqraoqKBKNZeATE' 30 | b'rXEtnoEr+wnT3p8tT+vL+29I4=.sha256"}]},"signature":"+i4U0HUGDDEyNoNr2NIROPnT3WQj3RuTaIhY5koWW8f0vwr4tZsY' 31 | b'mAkqqMwFWfP+eBIbc7DZ835er6r6h9CwAg==.sig.ed25519"}') 32 | 33 | 34 | class MockSHSSocket(SHSDuplexStream): 35 | def __init__(self, *args, **kwargs): 36 | super(MockSHSSocket, self).__init__() 37 | self.input = [] 38 | self.output = [] 39 | self.is_connected = False 40 | self._on_connect = [] 41 | 42 | def on_connect(self, cb): 43 | self._on_connect.append(cb) 44 | 45 | async def read(self): 46 | if not self.input: 47 | raise StopAsyncIteration 48 | return self.input.pop(0) 49 | 50 | def write(self, data): 51 | self.output.append(data) 52 | 53 | def feed(self, input): 54 | self.input += input 55 | 56 | def get_output(self): 57 | while True: 58 | if not self.output: 59 | break 60 | yield self.output.pop(0) 61 | 62 | def disconnect(self): 63 | self.is_connected = False 64 | 65 | 66 | class MockSHSClient(MockSHSSocket): 67 | async def connect(self): 68 | self.is_connected = True 69 | for cb in self._on_connect: 70 | await cb() 71 | 72 | 73 | class MockSHSServer(MockSHSSocket): 74 | def listen(self): 75 | self.is_connected = True 76 | for cb in self._on_connect: 77 | ensure_future(cb()) 78 | 79 | 80 | @pytest.fixture 81 | def ps_client(event_loop): 82 | return MockSHSClient() 83 | 84 | 85 | @pytest.fixture 86 | def ps_server(event_loop): 87 | return MockSHSServer() 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_on_connect(ps_server): 92 | called = Event() 93 | 94 | async def _on_connect(): 95 | called.set() 96 | 97 | ps_server.on_connect(_on_connect) 98 | ps_server.listen() 99 | await called.wait() 100 | assert ps_server.is_connected 101 | 102 | 103 | @pytest.mark.asyncio 104 | async def test_message_decoding(ps_client): 105 | await ps_client.connect() 106 | 107 | ps = PacketStream(ps_client) 108 | 109 | assert ps.is_connected 110 | 111 | ps_client.feed([ 112 | b'\n\x00\x00\x00\x9a\x00\x00\x04\xfb', 113 | b'{"name":["createHistoryStream"],"args":[{"id":"@omgyp7Pnrw+Qm0I6T6Fh5VvnKmodMXwnxTIesW2DgMg=.ed25519",' 114 | b'"seq":10,"live":true,"keys":false}],"type":"source"}' 115 | ]) 116 | 117 | messages = (await _collect_messages(ps)) 118 | assert len(messages) == 1 119 | assert messages[0].type == PSMessageType.JSON 120 | assert messages[0].body == { 121 | 'name': ['createHistoryStream'], 122 | 'args': [ 123 | { 124 | 'id': '@omgyp7Pnrw+Qm0I6T6Fh5VvnKmodMXwnxTIesW2DgMg=.ed25519', 125 | 'seq': 10, 126 | 'live': True, 127 | 'keys': False 128 | } 129 | ], 130 | 'type': 'source' 131 | } 132 | 133 | 134 | @pytest.mark.asyncio 135 | async def test_message_encoding(ps_client): 136 | await ps_client.connect() 137 | 138 | ps = PacketStream(ps_client) 139 | 140 | assert ps.is_connected 141 | 142 | ps.send({ 143 | 'name': ['createHistoryStream'], 144 | 'args': [{ 145 | 'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", 146 | 'seq': 1, 147 | 'live': False, 148 | 'keys': False 149 | }], 150 | 'type': 'source' 151 | }, stream=True) 152 | 153 | header, body = list(ps_client.get_output()) 154 | 155 | assert header == b'\x0a\x00\x00\x00\xa6\x00\x00\x00\x01' 156 | assert json.loads(body.decode('utf-8')) == { 157 | "name": ["createHistoryStream"], 158 | "args": [ 159 | {"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", "seq": 1, "live": False, "keys": False} 160 | ], 161 | "type": "source" 162 | } 163 | 164 | 165 | @pytest.mark.asyncio 166 | async def test_message_stream(ps_client, mocker): 167 | await ps_client.connect() 168 | 169 | ps = PacketStream(ps_client) 170 | mocker.patch.object(ps, 'register_handler', wraps=ps.register_handler) 171 | 172 | assert ps.is_connected 173 | 174 | ps.send({ 175 | 'name': ['createHistoryStream'], 176 | 'args': [{ 177 | 'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", 178 | 'seq': 1, 179 | 'live': False, 180 | 'keys': False 181 | }], 182 | 'type': 'source' 183 | }, stream=True) 184 | 185 | assert ps.req_counter == 2 186 | assert ps.register_handler.call_count == 1 187 | handler = list(ps._event_map.values())[0][1] 188 | 189 | with patch.object(handler, 'process') as mock_process: 190 | ps_client.feed([b'\n\x00\x00\x02\xc5\xff\xff\xff\xff', MSG_BODY_1]) 191 | msg = await ps.read() 192 | assert mock_process.call_count == 1 193 | 194 | # responses have negative req 195 | assert msg.req == -1 196 | assert msg.body['previous'] == '%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256' 197 | 198 | assert ps.req_counter == 2 199 | 200 | stream_handler = ps.send({ 201 | 'name': ['createHistoryStream'], 202 | 'args': [{ 203 | 'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", 204 | 'seq': 1, 205 | 'live': False, 206 | 'keys': False 207 | }], 208 | 'type': 'source' 209 | }, stream=True) 210 | 211 | assert ps.req_counter == 3 212 | assert ps.register_handler.call_count == 2 213 | handler = list(ps._event_map.values())[1][1] 214 | 215 | with patch.object(handler, 'process', wraps=handler.process) as mock_process: 216 | ps_client.feed([b'\n\x00\x00\x02\xc5\xff\xff\xff\xfe', MSG_BODY_1, 217 | b'\x0e\x00\x00\x023\xff\xff\xff\xfe', MSG_BODY_2]) 218 | 219 | # execute both message polling and response handling loops 220 | collected, handled = await gather(_collect_messages(ps), _collect_messages(stream_handler)) 221 | 222 | # No messages collected, since they're all responses 223 | assert collected == [] 224 | 225 | assert mock_process.call_count == 2 226 | 227 | for msg in handled: 228 | # responses have negative req 229 | assert msg.req == -2 230 | 231 | 232 | @pytest.mark.asyncio 233 | async def test_message_request(ps_server, mocker): 234 | ps_server.listen() 235 | 236 | ps = PacketStream(ps_server) 237 | 238 | mocker.patch.object(ps, 'register_handler', wraps=ps.register_handler) 239 | 240 | ps.send({ 241 | 'name': ['whoami'], 242 | 'args': [] 243 | }) 244 | 245 | header, body = list(ps_server.get_output()) 246 | assert header == b'\x02\x00\x00\x00 \x00\x00\x00\x01' 247 | assert json.loads(body.decode('utf-8')) == {"name": ["whoami"], "args": []} 248 | 249 | assert ps.req_counter == 2 250 | assert ps.register_handler.call_count == 1 251 | handler = list(ps._event_map.values())[0][1] 252 | 253 | with patch.object(handler, 'process') as mock_process: 254 | ps_server.feed([b'\x02\x00\x00\x00>\xff\xff\xff\xff', 255 | b'{"id":"@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519"}']) 256 | msg = await ps.read() 257 | assert mock_process.call_count == 1 258 | 259 | # responses have negative req 260 | assert msg.req == -1 261 | assert msg.body['id'] == '@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519' 262 | assert ps.req_counter == 2 263 | -------------------------------------------------------------------------------- /ssb/tests/test_util.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from unittest.mock import mock_open, patch 3 | 4 | import pytest 5 | 6 | from ssb.util import load_ssb_secret, ConfigException 7 | 8 | 9 | CONFIG_FILE = """ 10 | ## Comments should be supported too 11 | { 12 | "curve": "ed25519", 13 | "public": "rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=ed25519", 14 | "private": "/bqDBI/vGLD5qy3GxMsgHFgYIrrY08JfTzUaCYT6x0GuxikEhxezGNAB/Qk16z4wepPYMv4R+ilYoCnisZ4Q9A==", 15 | "id": "@rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=.ed25519" 16 | } 17 | """ 18 | 19 | CONFIG_FILE_INVALID = CONFIG_FILE.replace('ed25519', 'foo') 20 | 21 | 22 | def test_load_secret(): 23 | with patch('ssb.util.open', mock_open(read_data=CONFIG_FILE), create=True): 24 | secret = load_ssb_secret() 25 | 26 | priv_key = b'\xfd\xba\x83\x04\x8f\xef\x18\xb0\xf9\xab-\xc6\xc4\xcb \x1cX\x18"\xba\xd8\xd3\xc2_O5\x1a\t\x84\xfa\xc7A' 27 | 28 | assert secret['id'] == '@rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=.ed25519' 29 | assert bytes(secret['keypair']) == priv_key 30 | assert bytes(secret['keypair'].verify_key) == b64decode('rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=') 31 | 32 | 33 | def test_load_exception(): 34 | with pytest.raises(ConfigException): 35 | with patch('ssb.util.open', mock_open(read_data=CONFIG_FILE_INVALID), create=True): 36 | load_ssb_secret() 37 | -------------------------------------------------------------------------------- /ssb/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | from base64 import b64decode, b64encode 4 | 5 | from nacl.signing import SigningKey 6 | 7 | 8 | class ConfigException(Exception): 9 | pass 10 | 11 | 12 | def tag(key): 13 | """Create tag from publick key.""" 14 | return b'@' + b64encode(bytes(key)) + b'.ed25519' 15 | 16 | 17 | def load_ssb_secret(): 18 | """Load SSB keys from ~/.ssb""" 19 | with open(os.path.expanduser('~/.ssb/secret')) as f: 20 | config = yaml.load(f, Loader=yaml.SafeLoader) 21 | 22 | if config['curve'] != 'ed25519': 23 | raise ConfigException('Algorithm not known: ' + config['curve']) 24 | 25 | server_prv_key = b64decode(config['private'][:-8]) 26 | return { 27 | 'keypair': SigningKey(server_prv_key[:32]), 28 | 'id': config['id'] 29 | } 30 | --------------------------------------------------------------------------------