Compare commits

...

25 Commits
dev ... main

Author SHA1 Message Date
11d09b76df
test: Bring the MockSHSSocket testing class in line with the secret-handshake library 2023-11-19 07:42:25 +01:00
377368509e
build(typing): Add stricter type hinting to LocalMessage’s feed and previous attribute 2023-11-19 07:30:31 +01:00
59c99097a2
refactor: Use SHSDuplexStream.close instead of disconnect
`disconnect()` is now deprecated to make the API more consistent.
2023-11-19 07:28:06 +01:00
c2fffafa4e
feat: Create the PSHandler type 2023-11-19 07:26:23 +01:00
55abb6e341
ci: Enable (almost) all PyLint messages 2023-11-18 07:15:17 +01:00
526117ae18
chore: Remove useless PyLint suppressions 2023-11-18 07:15:17 +01:00
d6881cd8d5
feat: Process boolean True responses over Packet Stream 2023-11-18 07:07:12 +01:00
9b54ea6cec
chore: Remove unnecessary return type markup 2023-11-18 07:03:39 +01:00
df8c79514a
chore: Remove a useless super().__init__() call from PSStreamHandler 2023-11-18 07:01:14 +01:00
0c17410e18
style: Styling i forgot to commit earlier 2023-11-18 06:59:39 +01:00
a85fce7e3f
test: Fix test_packet_stream.py so there are no coverage issues in it 2023-11-18 06:40:35 +01:00
9e4a254127
test: Fully cover feed/models.py with tests 2023-11-18 06:40:35 +01:00
1c1e57d868
ci: Add and configure mypy, and make it happy 2023-11-16 09:44:36 +01:00
f2a54b5ce6
chore: Get rid of all __await__ methods 2023-11-16 06:12:12 +01:00
b30603a190
feat: Make it possible to load SSB secrets from a non-default file 2023-11-16 05:29:52 +01:00
c588967c11 build: Remove async_generator as a dependency
Python 3.6+ can do this natively.
2023-11-15 14:32:00 +01:00
01e8de96b2
fix: Check for empty messages before checking its properties in MuxRPCAPI 2023-11-15 06:13:09 +01:00
6ddb8bc8d3
test: Add tests for feed utils 2023-11-15 05:46:54 +01:00
69433cab86
refactor: Change MuxRPC.__await__ MuxRPC.process_messages
`__await__` can be a bit dangerous in the world of `asyncio`.
2023-11-15 05:36:29 +01:00
3b2c5cc792 test: Let mocker.path.object figure out the mock type to use in packet stream tests 2023-11-14 13:05:09 +01:00
d51f27d883
ci: Add and configure PyLint, and make it happy 2023-11-14 06:03:46 +01:00
e0cd456e77
chore: Remove the unused decorated function in MuxRPCAPI.define 2023-11-14 05:21:35 +01:00
8f5d355ea0
ci: Update and configure isort, and make it happy 2023-11-14 05:16:10 +01:00
d1a0510734
ci: Use black instead of flake8
It results in mostly the same style, plus it’s configurable via pyproject.toml.
2023-11-13 13:57:42 +01:00
50442c56fe
chore: Make the project REUSE compatible 2023-11-13 13:41:30 +01:00
19 changed files with 1651 additions and 534 deletions

View File

@ -16,3 +16,37 @@ repos:
pass_filenames: false
language: system
stages: [push]
- id: black
name: black
description: "Black: The uncompromising Python code formatter"
entry: poetry run black
args: ["--diff"]
language: system
require_serial: true
types_or: [python, pyi]
- id: pylint
name: pylint
entry: poetry run pylint
language: system
types: [python]
require_serial: true
- id: isort
name: isort
args: ["--check", "--diff"]
entry: poetry run isort
language: system
require_serial: true
types_or: [python, pyi]
- id: mypy
name: mypy
entry: poetry run mypy
args: ["--strict"]
language: system
types_or: [python, pyi]
require_serial: true
- id: reuse
name: reuse
entry: poetry run reuse
args: ["lint"]
language: system
pass_filenames: false

8
.reuse/dep5 Normal file
View File

@ -0,0 +1,8 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: PySSB
Upstream-Contact: Pedro Ferreira <pedro@dete.st>
Source: https://github.com/pferreir/pyssb
Files: AUTHORS README.rst poetry.lock .gitignore .pre-commit-config.yaml
Copyright: 2023 Gergely Polonkai <python-ssb@gergely.polonkai.eu>
License: CC0-1.0

19
LICENSE
View File

@ -1,19 +0,0 @@
Copyright (c) 2017 pyssb contributors (see AUTHORS for more details)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
LICENSES/CC0-1.0.txt Normal file
View File

@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

9
LICENSES/MIT.txt Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,93 +1,140 @@
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Example SSB Client"""
from asyncio import ensure_future, gather, get_event_loop
import base64
import hashlib
import logging
import struct
import time
from asyncio import get_event_loop, gather, ensure_future
from colorlog import ColoredFormatter
from nacl.signing import SigningKey
from secret_handshake.network import SHSClient
from ssb.muxrpc import MuxRPCAPI, MuxRPCAPIException
from ssb.muxrpc import MuxRPCAPI, MuxRPCAPIException, MuxRPCRequest
from ssb.packet_stream import PacketStream, PSMessageType
from ssb.util import load_ssb_secret
import hashlib
import base64
api = MuxRPCAPI()
@api.define('createHistoryStream')
def create_history_stream(connection, msg):
print('create_history_stream', msg)
@api.define("createHistoryStream")
def create_history_stream(connection: PacketStream, msg: MuxRPCRequest) -> None: # pylint: disable=unused-argument
"""Handle the createHistoryStream RPC call"""
print("create_history_stream", msg)
# msg = PSMessage(PSMessageType.JSON, True, stream=True, end_err=True, req=-req)
# connection.write(msg)
@api.define('blobs.createWants')
def create_wants(connection, msg):
print('create_wants', msg)
@api.define("blobs.createWants")
def create_wants(connection: PacketStream, msg: MuxRPCRequest) -> None: # pylint: disable=unused-argument
"""Handle the createWants RPC call"""
print("create_wants", msg)
async def test_client():
async for msg in api.call('createHistoryStream', [{
'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
'seq': 1,
'live': False,
'keys': False
}], 'source'):
print('> RESPONSE:', msg)
async def test_client() -> None:
"""The actual client implementation"""
async for msg in api.call(
"createHistoryStream",
[
{
"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
"seq": 1,
"live": False,
"keys": False,
}
],
"source",
):
print("> RESPONSE:", msg)
try:
print('> RESPONSE:', await api.call('whoami', [], 'sync'))
response_handler = api.call("whoami", [], "sync")
response = await response_handler.get_response()
print("> RESPONSE:", response)
except MuxRPCAPIException as e:
print(e)
handler = api.call('gossip.ping', [], 'duplex')
handler.send(struct.pack('l', int(time.time() * 1000)), msg_type=PSMessageType.BUFFER)
handler = api.call("gossip.ping", [], "duplex")
handler.send(struct.pack("l", int(time.time() * 1000)), msg_type=PSMessageType.BUFFER)
async for msg in handler:
print('> RESPONSE:', msg)
print("> RESPONSE:", msg)
handler.send(True, end=True)
break
img_data = b''
async for msg in api.call('blobs.get', ['&kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM=.sha256'], 'source'):
if msg.type.name == 'BUFFER':
img_data = b""
async for msg in api.call("blobs.get", ["&kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM=.sha256"], "source"):
assert msg
if msg.type.name == "BUFFER":
img_data += msg.data
if msg.type.name == 'JSON' and msg.data == b'true':
assert (base64.b64encode(hashlib.sha256(img_data).digest()) ==
b'kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM=')
with open('./ub1k.jpg', 'wb') as f:
if msg.type.name == "JSON" and msg.data == b"true":
assert (
base64.b64encode(hashlib.sha256(img_data).digest()) == b"kqZ52sDcJSHOx7m4Ww80kK1KIZ65gpGnqwZlfaIVWWM="
)
with open("./ub1k.jpg", "wb") as f:
f.write(img_data)
async def main():
client = SHSClient('127.0.0.1', 8008, keypair, bytes(keypair.verify_key))
async def main(keypair: SigningKey) -> None:
"""The main function to run"""
client = SHSClient("127.0.0.1", 8008, keypair, bytes(keypair.verify_key))
packet_stream = PacketStream(client)
await client.open()
api.add_connection(packet_stream)
await gather(ensure_future(api), test_client())
await gather(ensure_future(api.process_messages()), test_client())
if __name__ == '__main__':
if __name__ == "__main__":
# create console handler and set level to debug
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
# create formatter
formatter = ColoredFormatter('%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - '
'%(cyan)s%(message)s%(reset)s')
formatter = ColoredFormatter(
"%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - %(cyan)s%(message)s%(reset)s"
)
# add formatter to ch
ch.setFormatter(formatter)
# add ch to logger
logger = logging.getLogger('packet_stream')
logger = logging.getLogger("packet_stream")
logger.setLevel(logging.INFO)
logger.addHandler(ch)
keypair = load_ssb_secret()['keypair']
ssb_keypair = load_ssb_secret()["keypair"]
loop = get_event_loop()
loop.run_until_complete(main())
loop.run_until_complete(main(ssb_keypair))
loop.close()

View File

@ -1,45 +1,76 @@
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Test SSB server"""
from asyncio import get_event_loop
import logging
from asyncio import gather, get_event_loop, ensure_future
from colorlog import ColoredFormatter
from secret_handshake import SHSServer
from ssb.packet_stream import PacketStream
from secret_handshake.network import SHSDuplexStream
from ssb.muxrpc import MuxRPCAPI
from ssb.packet_stream import PacketStream
from ssb.util import load_ssb_secret
api = MuxRPCAPI()
async def on_connect(conn):
async def on_connect(conn: SHSDuplexStream) -> None:
"""Incoming connection handler"""
packet_stream = PacketStream(conn)
api.add_connection(packet_stream)
print('connect', conn)
print("connect", conn)
async for msg in packet_stream:
print(msg)
async def main():
server = SHSServer('127.0.0.1', 8008, load_ssb_secret()['keypair'])
async def main() -> None:
"""The main function to run"""
server = SHSServer("127.0.0.1", 8008, load_ssb_secret()["keypair"])
server.on_connect(on_connect)
await server.listen()
if __name__ == '__main__':
if __name__ == "__main__":
# create console handler and set level to debug
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
# create formatter
formatter = ColoredFormatter('%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - '
'%(cyan)s%(message)s%(reset)s')
formatter = ColoredFormatter(
"%(log_color)s%(levelname)s%(reset)s:%(bold_white)s%(name)s%(reset)s - %(cyan)s%(message)s%(reset)s"
)
# add formatter to ch
ch.setFormatter(formatter)
# add ch to logger
logger = logging.getLogger('packet_stream')
logger = logging.getLogger("packet_stream")
logger.setLevel(logging.DEBUG)
logger.addHandler(ch)

262
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
[[package]]
name = "alabaster"
@ -25,6 +25,20 @@ files = [
[package.extras]
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
[[package]]
name = "astroid"
version = "3.0.1"
description = "An abstract syntax tree for Python with inference support."
optional = false
python-versions = ">=3.8.0"
files = [
{file = "astroid-3.0.1-py3-none-any.whl", hash = "sha256:7d5895c9825e18079c5aeac0572bc2e4c83205c95d416e0b4fee8bc361d2d9ca"},
{file = "astroid-3.0.1.tar.gz", hash = "sha256:86b0bb7d7da0be1a7c4aedb7974e391b32d4ed89e33de6ed6902b4b15c97577e"},
]
[package.dependencies]
typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""}
[[package]]
name = "async-generator"
version = "1.8"
@ -53,6 +67,48 @@ setuptools = {version = "*", markers = "python_version >= \"3.12\""}
[package.extras]
dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
[[package]]
name = "black"
version = "23.11.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-23.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911"},
{file = "black-23.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f"},
{file = "black-23.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394"},
{file = "black-23.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f"},
{file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"},
{file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"},
{file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"},
{file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"},
{file = "black-23.11.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187"},
{file = "black-23.11.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6"},
{file = "black-23.11.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b"},
{file = "black-23.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142"},
{file = "black-23.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055"},
{file = "black-23.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4"},
{file = "black-23.11.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06"},
{file = "black-23.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07"},
{file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"},
{file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "certifi"
version = "2023.7.22"
@ -244,6 +300,20 @@ toml = "*"
[package.extras]
test = ["mock"]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
@ -374,6 +444,20 @@ files = [
{file = "decli-0.6.1.tar.gz", hash = "sha256:ed88ccb947701e8e5509b7945fda56e150e2ac74a69f25d47ac85ef30ab0c0f0"},
]
[[package]]
name = "dill"
version = "0.3.7"
description = "serialize all of Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"},
{file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"},
]
[package.extras]
graph = ["objgraph (>=1.7.2)"]
[[package]]
name = "docutils"
version = "0.17.1"
@ -453,20 +537,20 @@ files = [
[[package]]
name = "isort"
version = "4.3.21"
version = "5.12.0"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
python-versions = ">=3.8.0"
files = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
{file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
{file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
]
[package.extras]
pipfile = ["pipreqs", "requirementslib"]
pyproject = ["toml"]
requirements = ["pip-api", "pipreqs"]
xdg-home = ["appdirs (>=1.4.0)"]
colors = ["colorama (>=0.4.3)"]
pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "jinja2"
@ -554,6 +638,75 @@ files = [
{file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "mypy"
version = "1.7.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5da84d7bf257fd8f66b4f759a904fd2c5a765f70d8b52dde62b521972a0a2357"},
{file = "mypy-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a3637c03f4025f6405737570d6cbfa4f1400eb3c649317634d273687a09ffc2f"},
{file = "mypy-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b633f188fc5ae1b6edca39dae566974d7ef4e9aaaae00bc36efe1f855e5173ac"},
{file = "mypy-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d6ed9a3997b90c6f891138e3f83fb8f475c74db4ccaa942a1c7bf99e83a989a1"},
{file = "mypy-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:1fe46e96ae319df21359c8db77e1aecac8e5949da4773c0274c0ef3d8d1268a9"},
{file = "mypy-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df67fbeb666ee8828f675fee724cc2cbd2e4828cc3df56703e02fe6a421b7401"},
{file = "mypy-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a79cdc12a02eb526d808a32a934c6fe6df07b05f3573d210e41808020aed8b5d"},
{file = "mypy-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65f385a6f43211effe8c682e8ec3f55d79391f70a201575def73d08db68ead1"},
{file = "mypy-1.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e81ffd120ee24959b449b647c4b2fbfcf8acf3465e082b8d58fd6c4c2b27e46"},
{file = "mypy-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:f29386804c3577c83d76520abf18cfcd7d68264c7e431c5907d250ab502658ee"},
{file = "mypy-1.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:87c076c174e2c7ef8ab416c4e252d94c08cd4980a10967754f91571070bf5fbe"},
{file = "mypy-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cb8d5f6d0fcd9e708bb190b224089e45902cacef6f6915481806b0c77f7786d"},
{file = "mypy-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93e76c2256aa50d9c82a88e2f569232e9862c9982095f6d54e13509f01222fc"},
{file = "mypy-1.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cddee95dea7990e2215576fae95f6b78a8c12f4c089d7e4367564704e99118d3"},
{file = "mypy-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d01921dbd691c4061a3e2ecdbfbfad029410c5c2b1ee88946bf45c62c6c91210"},
{file = "mypy-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:185cff9b9a7fec1f9f7d8352dff8a4c713b2e3eea9c6c4b5ff7f0edf46b91e41"},
{file = "mypy-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7b1e399c47b18feb6f8ad4a3eef3813e28c1e871ea7d4ea5d444b2ac03c418"},
{file = "mypy-1.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9fe455ad58a20ec68599139ed1113b21f977b536a91b42bef3ffed5cce7391"},
{file = "mypy-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d0fa29919d2e720c8dbaf07d5578f93d7b313c3e9954c8ec05b6d83da592e5d9"},
{file = "mypy-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b53655a295c1ed1af9e96b462a736bf083adba7b314ae775563e3fb4e6795f5"},
{file = "mypy-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1b06b4b109e342f7dccc9efda965fc3970a604db70f8560ddfdee7ef19afb05"},
{file = "mypy-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf7a2f0a6907f231d5e41adba1a82d7d88cf1f61a70335889412dec99feeb0f8"},
{file = "mypy-1.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:551d4a0cdcbd1d2cccdcc7cb516bb4ae888794929f5b040bb51aae1846062901"},
{file = "mypy-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55d28d7963bef00c330cb6461db80b0b72afe2f3c4e2963c99517cf06454e665"},
{file = "mypy-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:870bd1ffc8a5862e593185a4c169804f2744112b4a7c55b93eb50f48e7a77010"},
{file = "mypy-1.7.0-py3-none-any.whl", hash = "sha256:96650d9a4c651bc2a4991cf46f100973f656d69edc7faf91844e87fe627f7e96"},
{file = "mypy-1.7.0.tar.gz", hash = "sha256:1e280b5697202efa698372d2f39e9a6713a0395a756b1c6bd48995f8d72690dc"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.1.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.2"
@ -565,6 +718,17 @@ files = [
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
]
[[package]]
name = "pathspec"
version = "0.11.2"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.7"
files = [
{file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"},
{file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
]
[[package]]
name = "pep257"
version = "0.7.0"
@ -576,6 +740,21 @@ files = [
{file = "pep257-0.7.0.tar.gz", hash = "sha256:f3d67547f5617a9cfeb4b8097ed94a954888315defaf6e9b518ff1719363bf03"},
]
[[package]]
name = "platformdirs"
version = "4.0.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
]
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"]
[[package]]
name = "pluggy"
version = "1.3.0"
@ -630,6 +809,36 @@ files = [
[package.extras]
plugins = ["importlib-metadata"]
[[package]]
name = "pylint"
version = "3.0.2"
description = "python code static checker"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "pylint-3.0.2-py3-none-any.whl", hash = "sha256:60ed5f3a9ff8b61839ff0348b3624ceeb9e6c2a92c514d81c9cc273da3b6bcda"},
{file = "pylint-3.0.2.tar.gz", hash = "sha256:0d4c286ef6d2f66c8bfb527a7f8a629009e42c99707dec821a03e1b51a4c1496"},
]
[package.dependencies]
astroid = ">=3.0.1,<=3.1.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.2", markers = "python_version < \"3.11\""},
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
{version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""},
]
isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.8"
platformdirs = ">=2.2.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
tomlkit = ">=0.10.1"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
spelling = ["pyenchant (>=3.2,<4.0)"]
testutils = ["gitpython (>3)"]
[[package]]
name = "pynacl"
version = "1.1.2"
@ -1091,6 +1300,39 @@ files = [
{file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"},
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.12"
description = "Typing stubs for PyYAML"
optional = false
python-versions = "*"
files = [
{file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"},
{file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"},
]
[[package]]
name = "types-simplejson"
version = "3.19.0.2"
description = "Typing stubs for simplejson"
optional = false
python-versions = "*"
files = [
{file = "types-simplejson-3.19.0.2.tar.gz", hash = "sha256:ebc81f886f89d99d6b80c726518aa2228bc77c26438f18fd81455e4f79f8ee1b"},
{file = "types_simplejson-3.19.0.2-py3-none-any.whl", hash = "sha256:8ba093dc7884f59b3e62aed217144085e675a269debc32678fd80e0b43b2b86f"},
]
[[package]]
name = "typing-extensions"
version = "4.8.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"},
{file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"},
]
[[package]]
name = "urllib3"
version = "2.0.7"
@ -1137,4 +1379,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "d61fa17ada60d932d45c104bf4d45b79411c743d3eb129f65751c8d57c4bea15"
content-hash = "98384046072d2dd4f649a93231ee6a84e5b21be34f15d5d2196cd3832f15ebca"

View File

@ -1,3 +1,6 @@
# SPDX-License-Identifier: MIT
#
# SPDX-Copyright-Text: © 2017 PySSB contributors (see AUTHORS for more details)
[tool.poetry]
name = "ssb"
version = "0.1.0"
@ -5,10 +8,10 @@ description = "Secure Scuttlebutt library in Python"
authors = ["PyScuttleButt Contributors <pedro@dete.st>"]
license = "MIT"
readme = "README.rst"
include = ["ssb/py.typed"]
[tool.poetry.dependencies]
python = "^3.9"
async_generator = "^1.8"
PyNaCl = "^1.1.0"
PyYAML = "^6.0.1"
secret-handshake = { version = "0.1.0.dev3", allow-prereleases = true }
@ -16,19 +19,27 @@ simplejson = "3.16.0"
colorlog = "^6.7.0"
[tool.poetry.group.dev.dependencies]
black = "^23.10.1"
check-manifest = "^0.39"
commitizen = "^3.12.0"
coverage = "^7.3.2"
isort = "^4.3.20"
isort = "^5.12.0"
mypy = "^1.6.1"
pep257 = "^0.7.0"
pylint = "^3.0.2"
pytest = "^7.4.3"
pytest-asyncio = "^0.21.1"
pytest-cov = "^4.1.0"
pytest-mock = "^3.12.0"
commitizen = "^3.12.0"
types-pyyaml = "^6.0.12.12"
types-simplejson = "^3.19.0.2"
[tool.poetry.group.docs.dependencies]
Sphinx = "^2.1.1"
[tool.black]
line-length = 120
[tool.coverage.run]
branch = true
@ -38,6 +49,18 @@ skip_covered = true
fail_under = 70
omit = ["examples/*"]
[tool.isort]
force_sort_within_sections = true
line_length = 120
profile = "black"
[tool.pylint.messages_control]
enable = ["all"]
disable = ["locally-disabled", "suppressed-message"]
[tool.pylint.format]
max-line-length = 120
[tool.pytest.ini_options]
addopts = ["--cov=.", "--no-cov-on-fail"]
python_files = ["tests/test_*.py"]

View File

@ -1,2 +0,0 @@
[flake8]
max-line-length=120

View File

@ -1,3 +1,27 @@
from .models import Feed, LocalFeed, Message, LocalMessage, NoPrivateKeyException
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
__all__ = ('Feed', 'LocalFeed', 'Message', 'LocalMessage', 'NoPrivateKeyException')
"""Feed related functionality"""
from .models import Feed, LocalFeed, LocalMessage, Message, NoPrivateKeyException
__all__ = ("Feed", "LocalFeed", "Message", "LocalMessage", "NoPrivateKeyException")

View File

@ -1,125 +1,205 @@
import datetime
from base64 import b64encode
from collections import namedtuple, OrderedDict
from hashlib import sha256
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Feed models"""
from base64 import b64encode
from collections import OrderedDict, namedtuple
from datetime import datetime
from hashlib import sha256
from typing import Any, Dict, Optional
from nacl.signing import SigningKey, VerifyKey
from simplejson import dumps, loads
from typing_extensions import Self
from ssb.util import tag
OrderedMsg = namedtuple('OrderedMsg', ('previous', 'author', 'sequence', 'timestamp', 'hash', 'content'))
OrderedMsg = namedtuple("OrderedMsg", ("previous", "author", "sequence", "timestamp", "hash", "content"))
class NoPrivateKeyException(Exception):
pass
"""Exception to raise when a private key is not available"""
def to_ordered(data):
def to_ordered(data: Dict[str, Any]) -> OrderedDict[str, Any]:
"""Convert a dictionary to an ``OrderedDict``"""
smsg = OrderedMsg(**data)
return OrderedDict((k, getattr(smsg, k)) for k in smsg._fields)
def get_millis_1970():
return int(datetime.datetime.utcnow().timestamp() * 1000)
def get_millis_1970() -> int:
"""Get the UNIX timestamp in milliseconds"""
return int(datetime.utcnow().timestamp() * 1000)
class Feed(object):
def __init__(self, public_key):
class Feed:
"""Base class for feeds"""
def __init__(self, public_key: VerifyKey):
self.public_key = public_key
@property
def id(self):
return tag(self.public_key).decode('ascii')
def id(self) -> str:
"""The identifier of the feed"""
def sign(self, msg):
raise NoPrivateKeyException('Cannot use remote identity to sign (no private key!)')
return tag(self.public_key).decode("ascii")
def sign(self, msg: bytes) -> bytes:
"""Sign a message"""
raise NoPrivateKeyException("Cannot use remote identity to sign (no private key!)")
class LocalFeed(Feed):
def __init__(self, private_key):
"""Class representing a local feed"""
def __init__(self, private_key: SigningKey): # pylint: disable=super-init-not-called
self.private_key = private_key
@property
def public_key(self):
def public_key(self) -> VerifyKey:
"""The public key of the feed"""
return self.private_key.verify_key
def sign(self, msg):
@public_key.setter
def public_key(self, key: VerifyKey) -> None:
raise TypeError("Can not set only the public key for a local feed")
def sign(self, msg: bytes) -> bytes:
"""Sign a message for this feed"""
return self.private_key.sign(msg).signature
class Message(object):
def __init__(self, feed, content, signature, sequence=1, timestamp=None, previous=None):
class Message:
"""Base class for SSB messages"""
def __init__( # pylint: disable=too-many-arguments
self,
feed: Feed,
content: Dict[str, Any],
signature: Optional[str] = None,
sequence: int = 1,
timestamp: Optional[int] = None,
previous: Optional["Message"] = None,
):
self.feed = feed
self.content = content
if signature is None:
raise ValueError("signature can't be None")
self.signature = signature
self.previous = previous
self.timestamp = get_millis_1970() if timestamp is None else timestamp
if self.previous:
self.sequence = self.previous.sequence + 1
self.sequence: int = self.previous.sequence + 1
else:
self.sequence = sequence
self.timestamp = get_millis_1970() if timestamp is None else timestamp
self._check_signature()
def _check_signature(self) -> None:
if self.signature is None:
raise ValueError("signature can't be None")
@classmethod
def parse(cls, data, feed):
def parse(cls, data: bytes, feed: Feed) -> Self:
"""Parse raw message data"""
obj = loads(data, object_pairs_hook=OrderedDict)
msg = cls(feed, obj['content'], timestamp=obj['timestamp'])
msg = cls(feed, obj["content"], timestamp=obj["timestamp"])
return msg
def serialize(self, add_signature=True):
return dumps(self.to_dict(add_signature=add_signature), indent=2).encode('utf-8')
def serialize(self, add_signature: bool = True) -> bytes:
"""Serialize the message"""
def to_dict(self, add_signature=True):
obj = to_ordered({
'previous': self.previous.key if self.previous else None,
'author': self.feed.id,
'sequence': self.sequence,
'timestamp': self.timestamp,
'hash': 'sha256',
'content': self.content
})
return dumps(self.to_dict(add_signature=add_signature), indent=2).encode("utf-8")
def to_dict(self, add_signature: bool = True) -> OrderedDict[str, Any]:
"""Convert the message to a dictionary"""
obj = to_ordered(
{
"previous": self.previous.key if self.previous else None,
"author": self.feed.id,
"sequence": self.sequence,
"timestamp": self.timestamp,
"hash": "sha256",
"content": self.content,
}
)
if add_signature:
obj['signature'] = self.signature
obj["signature"] = self.signature
return obj
def verify(self, signature):
def verify(self, signature: str) -> bool:
"""Verify the signature of the message"""
return self.signature == signature
@property
def hash(self):
hash = sha256(self.serialize()).digest()
return b64encode(hash).decode('ascii') + '.sha256'
def hash(self) -> str:
"""The cryptographic hash of the message"""
hash_ = sha256(self.serialize()).digest()
return b64encode(hash_).decode("ascii") + ".sha256"
@property
def key(self):
return '%' + self.hash
def key(self) -> str:
"""The key of the message"""
return "%" + self.hash
class LocalMessage(Message):
def __init__(self, feed, content, signature=None, sequence=1, timestamp=None, previous=None):
self.feed = feed
self.content = content
"""Class representing a local message"""
self.previous = previous
if self.previous:
self.sequence = self.previous.sequence + 1
else:
self.sequence = sequence
feed: LocalFeed
previous: "LocalMessage"
self.timestamp = get_millis_1970() if timestamp is None else timestamp
def __init__( # pylint: disable=too-many-arguments
self,
feed: LocalFeed,
content: Dict[str, Any],
signature: Optional[str] = None,
sequence: int = 1,
timestamp: Optional[int] = None,
previous: Optional["LocalMessage"] = None,
):
super().__init__(feed, content, signature=signature, sequence=sequence, timestamp=timestamp, previous=previous)
if signature is None:
def _check_signature(self) -> None:
if self.signature is None:
self.signature = self._sign()
else:
self.signature = signature
def _sign(self):
def _sign(self) -> str:
# ensure ordering of keys and indentation of 2 characters, like ssb-keys
data = self.serialize(add_signature=False)
return (b64encode(bytes(self.feed.sign(data))) + b'.sig.ed25519').decode('ascii')
return (b64encode(bytes(self.feed.sign(data))) + b".sig.ed25519").decode("ascii")

View File

@ -1,140 +1,254 @@
from functools import wraps
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from async_generator import async_generator, yield_
"""MuxRPC"""
from ssb.packet_stream import PSMessageType
from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Optional, Union
from typing_extensions import Self
from .packet_stream import PacketStream, PSMessage, PSMessageType, PSRequestHandler, PSStreamHandler
MuxRPCJSON = Dict[str, Any]
MuxRPCCallType = Literal["async", "duplex", "sink", "source", "sync"]
MuxRPCRequestHandlerType = Callable[[PacketStream, "MuxRPCRequest"], None]
MuxRPCRequestParam = Union[bytes, str, MuxRPCJSON] # pylint: disable=invalid-name
class MuxRPCAPIException(Exception):
pass
"""Exception to raise on MuxRPC API errors"""
class MuxRPCHandler(object):
def check_message(self, msg):
class MuxRPCHandler:
"""Base MuxRPC handler class"""
def check_message(self, msg: PSMessage) -> None:
"""Check message validity"""
body = msg.body
if isinstance(body, dict) and 'name' in body and body['name'] == 'Error':
raise MuxRPCAPIException(body['message'])
if isinstance(body, dict) and "name" in body and body["name"] == "Error":
raise MuxRPCAPIException(body["message"])
def __aiter__(self) -> AsyncIterator[Optional[PSMessage]]:
raise NotImplementedError()
async def __anext__(self) -> Optional[PSMessage]:
raise NotImplementedError()
def send(self, msg: Any, msg_type: PSMessageType = PSMessageType.JSON, end: bool = False) -> None:
"""Send a message through the stream"""
raise NotImplementedError()
async def get_response(self) -> PSMessage:
"""Get the response for an RPC request"""
raise NotImplementedError()
class MuxRPCRequestHandler(MuxRPCHandler):
def __init__(self, ps_handler):
class MuxRPCRequestHandler(MuxRPCHandler): # pylint: disable=abstract-method
"""MuxRPC handler for incoming RPC requests"""
def __init__(self, ps_handler: PSRequestHandler):
self.ps_handler = ps_handler
def __await__(self):
msg = (yield from self.ps_handler.__await__())
async def get_response(self) -> PSMessage:
"""Get the response data"""
msg = await self.ps_handler.__anext__()
self.check_message(msg)
return msg
class MuxRPCSourceHandler(MuxRPCHandler):
def __init__(self, ps_handler):
class MuxRPCSourceHandler(MuxRPCHandler): # pylint: disable=abstract-method
"""MuxRPC handler for source-type RPC requests"""
def __init__(self, ps_handler: PSStreamHandler):
self.ps_handler = ps_handler
@async_generator
async def __aiter__(self):
async for msg in self.ps_handler:
try:
def __aiter__(self) -> AsyncIterator[Optional[PSMessage]]:
return self
async def __anext__(self) -> Optional[PSMessage]:
msg = await self.ps_handler.__anext__()
assert msg
self.check_message(msg)
await yield_(msg)
except MuxRPCAPIException:
raise
return msg
class MuxRPCSinkHandlerMixin(object):
class MuxRPCSinkHandlerMixin: # pylint: disable=too-few-public-methods
"""Mixin for sink-type MuxRPC handlers"""
connection: PacketStream
req: int
def send(self, msg: Any, msg_type: PSMessageType = PSMessageType.JSON, end: bool = False) -> None:
"""Send a message through the stream"""
def send(self, msg, msg_type=PSMessageType.JSON, end=False):
self.connection.send(msg, stream=True, msg_type=msg_type, req=self.req, end_err=end)
class MuxRPCDuplexHandler(MuxRPCSinkHandlerMixin, MuxRPCSourceHandler):
def __init__(self, ps_handler, connection, req):
super(MuxRPCDuplexHandler, self).__init__(ps_handler)
class MuxRPCDuplexHandler(MuxRPCSinkHandlerMixin, MuxRPCSourceHandler): # pylint: disable=abstract-method
"""MuxRPC handler for duplex streams"""
def __init__(self, ps_handler: PSStreamHandler, connection: PacketStream, req: int):
super().__init__(ps_handler)
self.connection = connection
self.req = req
class MuxRPCSinkHandler(MuxRPCHandler, MuxRPCSinkHandlerMixin):
def __init__(self, connection, req):
class MuxRPCSinkHandler(MuxRPCHandler, MuxRPCSinkHandlerMixin): # pylint: disable=abstract-method
"""MuxRPC handler for sinks"""
def __init__(self, connection: PacketStream, req: int):
self.connection = connection
self.req = req
def _get_appropriate_api_handler(type_, connection, ps_handler, req):
if type_ in {'sync', 'async'}:
def _get_appropriate_api_handler(
type_: MuxRPCCallType, connection: PacketStream, ps_handler: Union[PSRequestHandler, PSStreamHandler], req: int
) -> MuxRPCHandler:
"""Find the appropriate MuxRPC handler"""
if type_ in {"sync", "async"}:
assert isinstance(ps_handler, PSRequestHandler)
return MuxRPCRequestHandler(ps_handler)
elif type_ == 'source':
if type_ == "source":
assert isinstance(ps_handler, PSStreamHandler)
return MuxRPCSourceHandler(ps_handler)
elif type_ == 'sink':
if type_ == "sink":
return MuxRPCSinkHandler(connection, req)
elif type_ == 'duplex':
if type_ == "duplex":
assert isinstance(ps_handler, PSStreamHandler)
return MuxRPCDuplexHandler(ps_handler, connection, req)
raise TypeError(f"Unknown request type {type_}")
class MuxRPCRequest:
"""MuxRPC request"""
class MuxRPCRequest(object):
@classmethod
def from_message(cls, message):
body = message.body
return cls('.'.join(body['name']), body['args'])
def from_message(cls, message: PSMessage) -> Self:
"""Initialise a request from a raw packet stream message"""
def __init__(self, name, args):
body = message.body
assert isinstance(body, dict)
return cls(".".join(body["name"]), body["args"])
def __init__(self, name: str, args: List[MuxRPCRequestParam]):
self.name = name
self.args = args
def __repr__(self):
return '<MuxRPCRequest {0.name} {0.args}>'.format(self)
def __repr__(self) -> str:
return f"<MuxRPCRequest {self.name} {self.args}>"
class MuxRPCMessage(object):
class MuxRPCMessage:
"""MuxRPC message"""
@classmethod
def from_message(cls, message):
def from_message(cls, message: PSMessage) -> Self:
"""Initialise a MuxRPC message from a raw packet stream message"""
return cls(message.body)
def __init__(self, body):
def __init__(self, body: Union[bytes, str, Dict[str, Any], bool]):
self.body = body
def __repr__(self):
return '<MuxRPCMessage {0.body}}>'.format(self)
def __repr__(self) -> str:
return f"<MuxRPCMessage {self.body!r}>"
class MuxRPCAPI(object):
def __init__(self):
self.handlers = {}
self.connection = None
class MuxRPCAPI:
"""Generic MuxRPC API"""
def __init__(self) -> None:
self.handlers: Dict[str, MuxRPCRequestHandlerType] = {}
self.connection: Optional[PacketStream] = None
async def process_messages(self) -> None:
"""Continuously process incoming messages"""
assert self.connection
async def __await__(self):
async for req_message in self.connection:
body = req_message.body
if req_message is None:
return
if isinstance(body, dict) and body.get('name'):
body = req_message.body
if isinstance(body, dict) and body.get("name"):
self.process(self.connection, MuxRPCRequest.from_message(req_message))
def add_connection(self, connection):
def add_connection(self, connection: PacketStream) -> None:
"""Set the packet stream connection of this RPC API"""
self.connection = connection
def define(self, name):
def _handle(f):
def define(self, name: str) -> Callable[[MuxRPCRequestHandlerType], MuxRPCRequestHandlerType]:
"""Decorator to define an RPC method handler"""
def _handle(f: MuxRPCRequestHandlerType) -> MuxRPCRequestHandlerType:
self.handlers[name] = f
@wraps(f)
def _f(*args, **kwargs):
return f(*args, **kwargs)
return f
return _handle
def process(self, connection, request):
def process(self, connection: PacketStream, request: MuxRPCRequest) -> None:
"""Process an incoming request"""
handler = self.handlers.get(request.name)
if not handler:
raise MuxRPCAPIException('Method {} not found!'.format(request.name))
raise MuxRPCAPIException(f"Method {request.name} not found!")
handler(connection, request)
def call(self, name, args, type_='sync'):
def call(self, name: str, args: List[MuxRPCRequestParam], type_: MuxRPCCallType = "sync") -> MuxRPCHandler:
"""Call an RPC method"""
assert self.connection
if not self.connection.is_connected:
raise Exception('not connected')
raise Exception("not connected") # pylint: disable=broad-exception-raised
old_counter = self.connection.req_counter
ps_handler = self.connection.send({
'name': name.split('.'),
'args': args,
'type': type_
}, stream=type_ in {'sink', 'source', 'duplex'})
ps_handler = self.connection.send(
{"name": name.split("."), "args": args, "type": type_},
stream=type_ in {"sink", "source", "duplex"},
)
return _get_appropriate_api_handler(type_, self.connection, ps_handler, old_counter)

View File

@ -1,180 +1,295 @@
import logging
import struct
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Packet streams"""
from asyncio import Event, Queue
from enum import Enum
from time import time
import logging
from math import ceil
import struct
from time import time
from typing import Any, AsyncIterator, Dict, Optional, Tuple, Union
from secret_handshake.network import SHSDuplexStream
import simplejson
from async_generator import async_generator, yield_
from typing_extensions import Self
from secret_handshake import SHSClient, SHSServer
logger = logging.getLogger('packet_stream')
PSHandler = Union["PSRequestHandler", "PSStreamHandler"]
PSMessageData = Union[bytes, bool, Dict[str, Any], str]
logger = logging.getLogger("packet_stream")
class PSMessageType(Enum):
"""Available message types"""
BUFFER = 0
TEXT = 1
JSON = 2
class PSStreamHandler(object):
def __init__(self, req):
super(PSStreamHandler).__init__()
self.req = req
self.queue = Queue()
class PSStreamHandler:
"""Packet stream handler"""
def __init__(self, req: int):
self.req = req
self.queue: Queue["PSMessage"] = Queue()
async def process(self, msg: "PSMessage") -> None:
"""Process a pending message"""
async def process(self, msg):
await self.queue.put(msg)
async def stop(self):
await self.queue.put(None)
async def stop(self) -> None:
"""Stop a pending request"""
@async_generator
async def __aiter__(self):
while True:
# We use the None value internally to signal __anext__ that the stream can be closed. It is not used otherwise,
# hence the typing ignore
await self.queue.put(None) # type: ignore[arg-type]
def __aiter__(self) -> AsyncIterator[Optional["PSMessage"]]:
return self
async def __anext__(self) -> Optional["PSMessage"]:
elem = await self.queue.get()
if not elem:
return
await yield_(elem)
raise StopAsyncIteration()
return elem
class PSRequestHandler(object):
def __init__(self, req):
super(PSRequestHandler).__init__()
class PSRequestHandler:
"""Packet stream request handler"""
def __init__(self, req: int):
self.req = req
self.event = Event()
self._msg = None
self._msg: Optional["PSMessage"] = None
async def process(self, msg: "PSMessage") -> None:
"""Process a message request"""
async def process(self, msg):
self._msg = msg
self.event.set()
async def stop(self):
async def stop(self) -> None:
"""Stop a pending event request"""
if not self.event.is_set():
self.event.set()
def __await__(self):
def __aiter__(self) -> AsyncIterator["PSMessage"]:
return self
async def __anext__(self) -> "PSMessage":
# wait until 'process' is called
yield from self.event.wait().__await__()
await self.event.wait()
assert self._msg
return self._msg
class PSMessage(object):
class PSMessage:
"""Packet Stream message"""
@classmethod
def from_header_body(cls, flags, req, body):
def from_header_body(cls, flags: int, req: int, body: bytes) -> Self:
"""Parse a raw message"""
type_ = PSMessageType(flags & 0x03)
if type_ == PSMessageType.TEXT:
body = body.decode('utf-8')
decoded_body: Union[str, Dict[str, Any], bytes] = body.decode("utf-8")
elif type_ == PSMessageType.JSON:
body = simplejson.loads(body)
decoded_body = simplejson.loads(body)
else:
decoded_body = body
return cls(type_, body, bool(flags & 0x08), bool(flags & 0x04), req=req)
return cls(type_, decoded_body, bool(flags & 0x08), bool(flags & 0x04), req=req)
@property
def data(self):
def data(self) -> bytes:
"""The raw message data"""
if self.body is True:
return b"true"
if self.type == PSMessageType.TEXT:
return self.body.encode('utf-8')
elif self.type == PSMessageType.JSON:
return simplejson.dumps(self.body).encode('utf-8')
assert isinstance(self.body, str)
return self.body.encode("utf-8")
if self.type == PSMessageType.JSON:
assert isinstance(self.body, dict)
return simplejson.dumps(self.body).encode("utf-8")
assert isinstance(self.body, bytes)
return self.body
def __init__(self, type_, body, stream, end_err, req=None):
def __init__(
self,
type_: PSMessageType,
body: Union[bytes, str, Dict[str, Any], bool],
stream: bool,
end_err: bool,
req: Optional[int] = None,
): # pylint: disable=too-many-arguments
self.stream = stream
self.end_err = end_err
self.type = type_
self.body = body
self.req = req
def __repr__(self):
if self.type == PSMessageType.BUFFER:
body = '{} bytes'.format(len(self.body))
def __repr__(self) -> str:
if self.body is True:
body = "EOF"
elif self.type == PSMessageType.BUFFER:
assert isinstance(self.body, bytes)
body = f"{len(self.body)} bytes"
else:
body = self.body
return '<PSMessage ({}): {}{} {}{}>'.format(self.type.name, body,
'' if self.req is None else ' [{}]'.format(self.req),
'~' if self.stream else '', '!' if self.end_err else '')
body = str(self.body)
req = "" if self.req is None else f" [{self.req}]"
is_stream = "~" if self.stream else ""
err = "!" if self.end_err else ""
return f"<PSMessage ({self.type.name}): {body}{req} {is_stream}{err}>"
class PacketStream(object):
def __init__(self, connection):
class PacketStream:
"""SSB Packet stream"""
def __init__(self, connection: SHSDuplexStream):
self.connection = connection
self.req_counter = 1
self._event_map = {}
self._event_map: Dict[int, Tuple[float, PSHandler]] = {}
self._connected = False
def register_handler(self, handler: PSHandler) -> None:
"""Register an RPC handler"""
def register_handler(self, handler):
self._event_map[handler.req] = (time(), handler)
@property
def is_connected(self):
def is_connected(self) -> bool:
"""Check if the stream is connected"""
return self.connection.is_connected
@async_generator
async def __aiter__(self):
def __aiter__(self) -> AsyncIterator[Optional[PSMessage]]:
return self
async def __anext__(self) -> PSMessage:
while True:
msg = await self.read()
if not msg:
return
# filter out replies
if msg.req >= 0:
await yield_(msg)
raise StopAsyncIteration()
async def __await__(self):
async for data in self:
logger.info('RECV: %r', data)
if data is None:
return
if msg.req is not None and msg.req >= 0:
logger.info("RECV: %r", msg)
async def _read(self):
return msg
async def _read(self) -> Optional[PSMessage]:
try:
header = await self.connection.read()
if not header or header == b'\x00' * 9:
return
flags, length, req = struct.unpack('>BIi', header)
if not header or header == b"\x00" * 9:
return None
flags, length, req = struct.unpack(">BIi", header)
n_packets = ceil(length / 4096)
body = b""
body = b''
for n in range(n_packets):
body += await self.connection.read()
for _ in range(n_packets):
read_data = await self.connection.read()
if not read_data:
logger.debug("DISCONNECT")
self.connection.close()
return None
body += read_data
logger.debug("READ %s %s", header, len(body))
logger.debug('READ %s %s', header, len(body))
return PSMessage.from_header_body(flags, req, body)
except StopAsyncIteration:
logger.debug('DISCONNECT')
self.connection.disconnect()
logger.debug("DISCONNECT")
self.connection.close()
return None
async def read(self):
async def read(self) -> Optional[PSMessage]:
"""Read data from the packet stream"""
msg = await self._read()
if not msg:
return None
# check whether it's a reply and handle accordingly
if msg.req < 0:
t, handler = self._event_map[-msg.req]
if msg.req is not None and msg.req < 0:
_, handler = self._event_map[-msg.req]
await handler.process(msg)
logger.info('RESPONSE [%d]: %r', -msg.req, msg)
logger.info("RESPONSE [%d]: %r", -msg.req, msg)
if msg.end_err:
await handler.stop()
del self._event_map[-msg.req]
logger.info('RESPONSE [%d]: EOS', -msg.req)
logger.info("RESPONSE [%d]: EOS", -msg.req)
return msg
def _write(self, msg):
logger.info('SEND [%d]: %r', msg.req, msg)
header = struct.pack('>BIi', (int(msg.stream) << 3) | (int(msg.end_err) << 2) | msg.type.value, len(msg.data),
msg.req)
def _write(self, msg: PSMessage) -> None:
logger.info("SEND [%d]: %r", msg.req, msg)
header = struct.pack(
">BIi",
(int(msg.stream) << 3) | (int(msg.end_err) << 2) | msg.type.value,
len(msg.data),
msg.req,
)
self.connection.write(header)
self.connection.write(msg.data)
logger.debug('WRITE HDR: %s', header)
logger.debug('WRITE DATA: %s', msg.data)
logger.debug("WRITE HDR: %s", header)
logger.debug("WRITE DATA: %s", msg.data)
def send( # pylint: disable=too-many-arguments
self,
data: Union[bytes, str, Dict[str, Any]],
msg_type: PSMessageType = PSMessageType.JSON,
stream: bool = False,
end_err: bool = False,
req: Optional[int] = None,
) -> PSHandler:
"""Send data through the packet stream"""
def send(self, data, msg_type=PSMessageType.JSON, stream=False, end_err=False, req=None):
update_counter = False
if req is None:
update_counter = True
req = self.req_counter
@ -185,15 +300,19 @@ class PacketStream(object):
self._write(msg)
if stream:
handler = PSStreamHandler(self.req_counter)
handler: PSHandler = PSStreamHandler(self.req_counter)
else:
handler = PSRequestHandler(self.req_counter)
self.register_handler(handler)
if update_counter:
self.req_counter += 1
return handler
def disconnect(self):
def disconnect(self) -> None:
"""Disconnect the stream"""
self._connected = False
self.connection.disconnect()
self.connection.close()

0
ssb/py.typed Normal file
View File

View File

@ -1,29 +1,63 @@
import os
import yaml
from base64 import b64decode, b64encode
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from nacl.signing import SigningKey
"""Utility functions"""
from base64 import b64decode, b64encode
import os
from typing import Optional, TypedDict
from nacl.signing import SigningKey, VerifyKey
import yaml
class SSBSecret(TypedDict):
"""Dictionary to hold an SSB identity"""
keypair: SigningKey
id: str
class ConfigException(Exception):
pass
"""Exception to raise if there is a problem with the configuration data"""
def tag(key):
"""Create tag from publick key."""
return b'@' + b64encode(bytes(key)) + b'.ed25519'
def tag(key: VerifyKey) -> bytes:
"""Create tag from public key"""
return b"@" + b64encode(bytes(key)) + b".ed25519"
def load_ssb_secret():
"""Load SSB keys from ~/.ssb"""
with open(os.path.expanduser('~/.ssb/secret')) as f:
def load_ssb_secret(filename: Optional[str] = None) -> SSBSecret:
"""Load SSB keys from ``filename`` or, if unset, from ``~/.ssb/secret``"""
filename = filename or os.path.expanduser("~/.ssb/secret")
with open(filename, encoding="utf-8") as f:
config = yaml.load(f, Loader=yaml.SafeLoader)
if config['curve'] != 'ed25519':
raise ConfigException('Algorithm not known: ' + config['curve'])
if config["curve"] != "ed25519":
raise ConfigException("Algorithm not known: " + config["curve"])
server_prv_key = b64decode(config['private'][:-8])
return {
'keypair': SigningKey(server_prv_key[:32]),
'id': config['id']
}
server_prv_key = b64decode(config["private"][:-8])
return {"keypair": SigningKey(server_prv_key[:32]), "id": config["id"]}

View File

@ -1,11 +1,37 @@
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Tests for the feed functionality"""
from base64 import b64decode
from collections import OrderedDict
from datetime import datetime, timezone
import pytest
from nacl.signing import SigningKey, VerifyKey
import pytest
from pytest_mock import MockerFixture
from ssb.feed import LocalMessage, LocalFeed, Feed, Message, NoPrivateKeyException
from ssb.feed import Feed, LocalFeed, LocalMessage, Message, NoPrivateKeyException
from ssb.feed.models import get_millis_1970
SERIALIZED_M1 = b"""{
"previous": null,
@ -24,126 +50,222 @@ SERIALIZED_M1 = b"""{
@pytest.fixture()
def local_feed():
secret = b64decode('Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=')
def local_feed() -> LocalFeed:
"""Fixture providing a local feed"""
secret = b64decode("Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=")
return LocalFeed(SigningKey(secret))
@pytest.fixture()
def remote_feed():
public = b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=')
def remote_feed() -> Feed:
"""Fixture providing a remote feed"""
public = b64decode("I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=")
return Feed(VerifyKey(public))
def test_local_feed():
secret = b64decode('Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=')
def test_local_feed() -> None:
"""Test a local feed"""
secret = b64decode("Mz2qkNOP2K6upnqibWrR+z8pVUI1ReA1MLc7QMtF2qQ=")
feed = LocalFeed(SigningKey(secret))
assert bytes(feed.private_key) == secret
assert bytes(feed.public_key) == b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=')
assert feed.id == '@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519'
assert bytes(feed.public_key) == b64decode("I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=")
assert feed.id == "@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519"
def test_remote_feed():
public = b64decode('I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=')
def test_local_feed_set_pubkey(local_feed: LocalFeed) -> None: # pylint: disable=redefined-outer-name
"""Test setting only the public key for a local feed"""
key = SigningKey.generate().verify_key
with pytest.raises(TypeError) as ctx:
local_feed.public_key = key
assert str(ctx.value) == "Can not set only the public key for a local feed"
def test_remote_feed() -> None:
"""Test a remote feed"""
public = b64decode("I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=")
feed = Feed(VerifyKey(public))
assert bytes(feed.public_key) == public
assert feed.id == '@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519'
assert feed.id == "@I/4cyN/jPBbDsikbHzAEvmaYlaJK33lW3UhWjNXjyrU=.ed25519"
m1 = Message(feed, OrderedDict([
('type', 'about'),
('about', feed.id),
('name', 'neo'),
('description', 'The Chosen One')
]), 'foo', timestamp=1495706260190)
m1 = Message(
feed,
OrderedDict([("type", "about"), ("about", feed.id), ("name", "neo"), ("description", "The Chosen One")]),
"foo",
timestamp=1495706260190,
)
with pytest.raises(NoPrivateKeyException):
feed.sign(m1)
feed.sign(m1.serialize())
def test_local_message(local_feed):
m1 = LocalMessage(local_feed, OrderedDict([
('type', 'about'),
('about', local_feed.id),
('name', 'neo'),
('description', 'The Chosen One')
]), timestamp=1495706260190)
def test_local_message(local_feed: LocalFeed) -> None: # pylint: disable=redefined-outer-name
"""Test a local message"""
m1 = LocalMessage(
local_feed,
OrderedDict([("type", "about"), ("about", local_feed.id), ("name", "neo"), ("description", "The Chosen One")]),
timestamp=1495706260190,
)
assert m1.timestamp == 1495706260190
assert m1.previous is None
assert m1.sequence == 1
assert m1.signature == \
'lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519'
assert m1.key == '%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256'
assert (
m1.signature
== "lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519"
)
assert m1.key == "%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256"
m2 = LocalMessage(local_feed, OrderedDict([
('type', 'about'),
('about', local_feed.id),
('name', 'morpheus'),
('description', 'Dude with big jaw')
]), previous=m1, timestamp=1495706447426)
m2 = LocalMessage(
local_feed,
OrderedDict(
[
("type", "about"),
("about", local_feed.id),
("name", "morpheus"),
("description", "Dude with big jaw"),
]
),
previous=m1,
timestamp=1495706447426,
)
assert m2.timestamp == 1495706447426
assert m2.previous is m1
assert m2.sequence == 2
assert m2.signature == \
'3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519'
assert m2.key == '%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256'
assert (
m2.signature
== "3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519"
)
assert m2.key == "%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256"
def test_remote_message(remote_feed):
signature = 'lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519'
m1 = Message(remote_feed, OrderedDict([
('type', 'about'),
('about', remote_feed.id),
('name', 'neo'),
('description', 'The Chosen One')
]), signature, timestamp=1495706260190)
def test_remote_message(remote_feed: Feed) -> None: # pylint: disable=redefined-outer-name
"""Test a remote message"""
signature = "lPsQ9P10OgeyH6u0unFgiI2wV/RQ7Q2x2ebxnXYCzsJ055TBMXphRADTKhOMS2EkUxXQ9k3amj5fnWPudGxwBQ==.sig.ed25519"
m1 = Message(
remote_feed,
OrderedDict([("type", "about"), ("about", remote_feed.id), ("name", "neo"), ("description", "The Chosen One")]),
signature,
timestamp=1495706260190,
)
assert m1.timestamp == 1495706260190
assert m1.previous is None
assert m1.sequence == 1
assert m1.signature == signature
assert m1.key == '%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256'
assert m1.key == "%xRDqws/TrQmOd4aEwZ32jdLhP873ZKjIgHlggPR0eoo=.sha256"
signature = '3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519'
m2 = Message(remote_feed, OrderedDict([
('type', 'about'),
('about', remote_feed.id),
('name', 'morpheus'),
('description', 'Dude with big jaw')
]), signature, previous=m1, timestamp=1495706447426)
signature = "3SY85LX6/ppOfP4SbfwZbKfd6DccbLRiB13pwpzbSK0nU52OEJxOqcJ2Uensr6RkrWztWLIq90sNOn1zRAoOAw==.sig.ed25519"
m2 = Message(
remote_feed,
OrderedDict(
[("type", "about"), ("about", remote_feed.id), ("name", "morpheus"), ("description", "Dude with big jaw")]
),
signature,
previous=m1,
timestamp=1495706447426,
)
assert m2.timestamp == 1495706447426
assert m2.previous is m1
assert m2.sequence == 2
assert m2.signature == signature
m2.verify(signature)
assert m2.key == '%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256'
assert m2.key == "%nx13uks5GUwuKJC49PfYGMS/1pgGTtwwdWT7kbVaroM=.sha256"
def test_remote_no_signature(remote_feed):
def test_remote_no_signature(remote_feed: Feed) -> None: # pylint: disable=redefined-outer-name
"""Test remote feed without a signature"""
with pytest.raises(ValueError):
Message(remote_feed, OrderedDict([
('type', 'about'),
('about', remote_feed.id),
('name', 'neo'),
('description', 'The Chosen One')
]), None, timestamp=1495706260190)
Message(
remote_feed,
OrderedDict(
[("type", "about"), ("about", remote_feed.id), ("name", "neo"), ("description", "The Chosen One")]
),
None,
timestamp=1495706260190,
)
def test_serialize(local_feed):
m1 = LocalMessage(local_feed, OrderedDict([
('type', 'about'),
('about', local_feed.id),
('name', 'neo'),
('description', 'The Chosen One')
]), timestamp=1495706260190)
def test_serialize(local_feed: LocalFeed) -> None: # pylint: disable=redefined-outer-name
"""Test feed serialization"""
m1 = LocalMessage(
local_feed,
OrderedDict([("type", "about"), ("about", local_feed.id), ("name", "neo"), ("description", "The Chosen One")]),
timestamp=1495706260190,
)
assert m1.serialize() == SERIALIZED_M1
def test_parse(local_feed):
def test_parse(local_feed: LocalFeed) -> None: # pylint: disable=redefined-outer-name
"""Test feed parsing"""
m1 = LocalMessage.parse(SERIALIZED_M1, local_feed)
assert m1.content == {
'type': 'about',
'about': local_feed.id,
'name': 'neo',
'description': 'The Chosen One'
}
assert m1.content == {"type": "about", "about": local_feed.id, "name": "neo", "description": "The Chosen One"}
assert m1.timestamp == 1495706260190
def test_local_unsigned(local_feed: LocalFeed, mocker: MockerFixture) -> None: # pylint: disable=redefined-outer-name
"""Test creating an unsigned message on a local feed"""
mocked_dt = mocker.Mock(spec=datetime)
mocked_dt.utcnow = mocker.MagicMock(return_value=datetime(2023, 3, 7, 11, 45, 54, 0, tzinfo=timezone.utc))
mocker.patch("ssb.feed.models.datetime", mocked_dt)
msg = LocalMessage(local_feed, OrderedDict({"test": True}))
assert msg.feed == local_feed
assert msg.content == {"test": True}
assert msg.sequence == 1
assert msg.previous is None
assert msg.timestamp == 1678189554000
assert msg.signature == (
"WjkA5rjzsYDHqeavEPcbNAbRMp5NRFDBNATMWgcsccso8sfwhaWnIEvQW79fA5YgKKybzlIsCMWHherToEI2DA==.sig.ed25519"
)
def test_local_signed(local_feed: LocalFeed) -> None: # pylint: disable=redefined-outer-name
"""Test creating a signed message on a local feed"""
msg = LocalMessage(
local_feed,
OrderedDict({"test": True}),
timestamp=1678189554000,
signature=(
"WjkA5rjzsYDHqeavEPcbNAbRMp5NRFDBNATMWgcsccso8sfwhaWnIEvQW79fA5YgKKybzlIsCMWHherToEI2DA==.sig.ed25519"
),
)
assert msg.feed == local_feed
assert msg.content == {"test": True}
assert msg.sequence == 1
assert msg.previous is None
assert msg.timestamp == 1678189554000
assert msg.signature == (
"WjkA5rjzsYDHqeavEPcbNAbRMp5NRFDBNATMWgcsccso8sfwhaWnIEvQW79fA5YgKKybzlIsCMWHherToEI2DA==.sig.ed25519"
)
@pytest.mark.parametrize(
"timestamp,expected",
(
(datetime(2023, 3, 7, 11, 45, 54, 0, tzinfo=timezone.utc), 1678189554000),
(datetime(2013, 5, 2, 2, 3, 4, 567890, tzinfo=timezone.utc), 1367460184567),
),
)
def test_millis(timestamp: datetime, expected: int, mocker: MockerFixture) -> None:
"""Test the get_millis_1970() function"""
mocked_dt = mocker.Mock(spec=datetime)
mocked_dt.utcnow = mocker.MagicMock(return_value=timestamp)
mocker.patch("ssb.feed.models.datetime", mocked_dt)
assert get_millis_1970() == expected

View File

@ -1,96 +1,154 @@
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Tests for the packet stream"""
from asyncio import Event, ensure_future, gather
from asyncio.events import AbstractEventLoop
import json
from asyncio import ensure_future, gather, Event
from typing import AsyncIterator, Awaitable, Callable, Generator, List, Optional
import pytest
from nacl.signing import SigningKey
from pytest_mock import MockerFixture
from secret_handshake.network import SHSDuplexStream
from ssb.packet_stream import PacketStream, PSMessageType
from ssb.packet_stream import PacketStream, PSMessage, PSMessageType
async def _collect_messages(generator):
async def _collect_messages(generator: AsyncIterator[Optional[PSMessage]]) -> List[Optional["PSMessage"]]:
results = []
async for msg in generator:
async for msg in generator: # pragma: no branch
results.append(msg)
return results
MSG_BODY_1 = (b'{"previous":"%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA'
MSG_BODY_1 = (
b'{"previous":"%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA'
b'VvNNVH4F2BSxwhYmx8=.ed25519","sequence":116,"timestamp":1496696699331,"hash":"sha256","content":{"type"'
b':"post","channel":"crypto","text":"Does anybody know any good resources (e.g. books) to learn cryptogra'
b'phy? I\'m not speaking of basic concepts (e.g. what\'s a private key) but the actual mathematics behind'
b"phy? I'm not speaking of basic concepts (e.g. what's a private key) but the actual mathematics behind"
b' the whole thing.\\nI have a copy of the \\"Handbook of Applied Cryptography\\" on my bookshelf but I f'
b'ound it too long/hard to follow. Are there any better alternatives?","mentions":[]},"signature":"hqKePb'
b'bTXWxEi1njDnOWFsL0M0AoNoWyBFgNE6KXj//DThepaZSy9vRbygDHX5uNmCdyOrsQrwZsZhmUYKwtDQ==.sig.ed25519"}')
b'bTXWxEi1njDnOWFsL0M0AoNoWyBFgNE6KXj//DThepaZSy9vRbygDHX5uNmCdyOrsQrwZsZhmUYKwtDQ==.sig.ed25519"}'
)
MSG_BODY_2 = (b'{"previous":"%iQRhPyqmNLpGaO1Tpm1I22jqnUEwRwkCTDbwAGtM+lY=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA'
MSG_BODY_2 = (
b'{"previous":"%iQRhPyqmNLpGaO1Tpm1I22jqnUEwRwkCTDbwAGtM+lY=.sha256","author":"@1+Iwm79DKvVBqYKFkhT6fWRbA'
b'VvNNVH4F2BSxwhYmx8=.ed25519","sequence":103,"timestamp":1496674211806,"hash":"sha256","content":{"type"'
b':"post","channel":"git-ssb","text":"Is it only me or `git.scuttlebot.io` is timing out?\\n\\nE.g. try a'
b'ccessing %vZCTqraoqKBKNZeATErXEtnoEr+wnT3p8tT+vL+29I4=.sha256","mentions":[{"link":"%vZCTqraoqKBKNZeATE'
b'rXEtnoEr+wnT3p8tT+vL+29I4=.sha256"}]},"signature":"+i4U0HUGDDEyNoNr2NIROPnT3WQj3RuTaIhY5koWW8f0vwr4tZsY'
b'mAkqqMwFWfP+eBIbc7DZ835er6r6h9CwAg==.sig.ed25519"}')
b'mAkqqMwFWfP+eBIbc7DZ835er6r6h9CwAg==.sig.ed25519"}'
)
class MockSHSSocket(SHSDuplexStream):
def __init__(self, *args, **kwargs):
super(MockSHSSocket, self).__init__()
self.input = []
self.output = []
self.is_connected = False
self._on_connect = []
"""A mocked SHS socket"""
def __init__(self) -> None:
super().__init__()
self.input: List[bytes] = []
self.output: List[bytes] = []
self.is_connected: bool = False
self._on_connect: List[Callable[[SHSDuplexStream], Awaitable[None]]] = []
def on_connect(self, cb: Callable[[SHSDuplexStream], Awaitable[None]]) -> None:
"""Set the on_connect callback"""
def on_connect(self, cb):
self._on_connect.append(cb)
async def read(self):
async def read(self) -> bytes:
"""Read data from the socket"""
if not self.input:
raise StopAsyncIteration
raise StopAsyncIteration()
return self.input.pop(0)
def write(self, data):
def write(self, data: bytes) -> None:
"""Write data to the socket"""
self.output.append(data)
def feed(self, input):
self.input += input
def feed(self, input_: List[bytes]) -> None:
"""Feed data into the connection"""
self.input += input_
def get_output(self) -> Generator[bytes, None, None]:
"""Get the output of a call"""
def get_output(self):
while True:
if not self.output:
break
yield self.output.pop(0)
def disconnect(self):
self.is_connected = False
yield self.output.pop(0)
class MockSHSClient(MockSHSSocket):
async def connect(self):
"""A mocked SHS client"""
async def connect(self) -> None:
"""Connect to a SHS server"""
self.is_connected = True
for cb in self._on_connect:
await cb()
class MockSHSServer(MockSHSSocket):
def listen(self):
"""A mocked SHS server"""
def listen(self) -> None:
"""Listen for new connections"""
self.is_connected = True
for cb in self._on_connect:
ensure_future(cb())
ensure_future(cb(self))
@pytest.fixture
def ps_client(event_loop):
def ps_client(event_loop: AbstractEventLoop) -> MockSHSClient: # pylint: disable=unused-argument
"""Fixture to provide a mocked SHS client"""
return MockSHSClient()
@pytest.fixture
def ps_server(event_loop):
def ps_server(event_loop: AbstractEventLoop) -> MockSHSServer: # pylint: disable=unused-argument
"""Fixture to provide a mocked SHS server"""
return MockSHSServer()
@pytest.mark.asyncio
async def test_on_connect(ps_server):
async def test_on_connect(ps_server: MockSHSServer) -> None: # pylint: disable=redefined-outer-name
"""Test the on_connect callback functionality"""
called = Event()
async def _on_connect():
async def _on_connect(_: SHSDuplexStream) -> None:
called.set()
ps_server.on_connect(_on_connect)
@ -100,122 +158,160 @@ async def test_on_connect(ps_server):
@pytest.mark.asyncio
async def test_message_decoding(ps_client):
async def test_message_decoding(ps_client: MockSHSClient) -> None: # pylint: disable=redefined-outer-name
"""Test message decoding"""
await ps_client.connect()
ps = PacketStream(ps_client)
assert ps.is_connected
ps_client.feed([
b'\n\x00\x00\x00\x9a\x00\x00\x04\xfb',
ps_client.feed(
[
b"\n\x00\x00\x00\x9a\x00\x00\x04\xfb",
b'{"name":["createHistoryStream"],"args":[{"id":"@omgyp7Pnrw+Qm0I6T6Fh5VvnKmodMXwnxTIesW2DgMg=.ed25519",'
b'"seq":10,"live":true,"keys":false}],"type":"source"}'
])
b'"seq":10,"live":true,"keys":false}],"type":"source"}',
]
)
messages = (await _collect_messages(ps))
messages = await _collect_messages(ps)
assert len(messages) == 1
assert messages[0]
assert messages[0].type == PSMessageType.JSON
assert messages[0].body == {
'name': ['createHistoryStream'],
'args': [
"name": ["createHistoryStream"],
"args": [
{
'id': '@omgyp7Pnrw+Qm0I6T6Fh5VvnKmodMXwnxTIesW2DgMg=.ed25519',
'seq': 10,
'live': True,
'keys': False
"id": "@omgyp7Pnrw+Qm0I6T6Fh5VvnKmodMXwnxTIesW2DgMg=.ed25519",
"seq": 10,
"live": True,
"keys": False,
}
],
'type': 'source'
"type": "source",
}
@pytest.mark.asyncio
async def test_message_encoding(ps_client):
async def test_message_encoding(ps_client: MockSHSClient) -> None: # pylint: disable=redefined-outer-name
"""Test message encoding"""
await ps_client.connect()
ps = PacketStream(ps_client)
assert ps.is_connected
ps.send({
'name': ['createHistoryStream'],
'args': [{
'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
'seq': 1,
'live': False,
'keys': False
}],
'type': 'source'
}, stream=True)
ps.send(
{
"name": ["createHistoryStream"],
"args": [
{
"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
"seq": 1,
"live": False,
"keys": False,
}
],
"type": "source",
},
stream=True,
)
header, body = list(ps_client.get_output())
assert header == b'\x0a\x00\x00\x00\xa6\x00\x00\x00\x01'
assert json.loads(body.decode('utf-8')) == {
assert header == b"\x0a\x00\x00\x00\xa6\x00\x00\x00\x01"
assert json.loads(body.decode("utf-8")) == {
"name": ["createHistoryStream"],
"args": [
{"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519", "seq": 1, "live": False, "keys": False}
{
"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
"seq": 1,
"live": False,
"keys": False,
}
],
"type": "source"
"type": "source",
}
@pytest.mark.asyncio
async def test_message_stream(ps_client, mocker):
async def test_message_stream(
ps_client: MockSHSClient, mocker: MockerFixture # pylint: disable=redefined-outer-name
) -> None:
"""Test requesting a history stream"""
await ps_client.connect()
ps = PacketStream(ps_client)
mocker.patch.object(ps, 'register_handler', wraps=ps.register_handler)
mocker.patch.object(ps, "register_handler", wraps=ps.register_handler)
assert ps.is_connected
ps.send({
'name': ['createHistoryStream'],
'args': [{
'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
'seq': 1,
'live': False,
'keys': False
}],
'type': 'source'
}, stream=True)
ps.send(
{
"name": ["createHistoryStream"],
"args": [
{
"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
"seq": 1,
"live": False,
"keys": False,
}
],
"type": "source",
},
stream=True,
)
assert ps.req_counter == 2
assert ps.register_handler.call_count == 1
handler = list(ps._event_map.values())[0][1]
mock_process = mocker.AsyncMock()
assert ps.register_handler.call_count == 1 # type: ignore[attr-defined] # pylint: disable=no-member
handler = list(ps._event_map.values())[0][1] # pylint: disable=protected-access
mock_process = mocker.patch.object(handler, "process")
mocker.patch.object(handler, 'process', mock_process)
ps_client.feed([b'\n\x00\x00\x02\xc5\xff\xff\xff\xff', MSG_BODY_1])
ps_client.feed([b"\n\x00\x00\x02\xc5\xff\xff\xff\xff", MSG_BODY_1])
msg = await ps.read()
assert mock_process.await_count == 1
# responses have negative req
assert msg
assert isinstance(msg.body, dict)
assert msg.req == -1
assert msg.body['previous'] == '%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256'
assert msg.body["previous"] == "%KTGP6W8vF80McRAZHYDWuKOD0KlNyKSq6Gb42iuV7Iw=.sha256"
assert ps.req_counter == 2
stream_handler = ps.send({
'name': ['createHistoryStream'],
'args': [{
'id': "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
'seq': 1,
'live': False,
'keys': False
}],
'type': 'source'
}, stream=True)
stream_handler = ps.send(
{
"name": ["createHistoryStream"],
"args": [
{
"id": "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519",
"seq": 1,
"live": False,
"keys": False,
}
],
"type": "source",
},
stream=True,
)
assert ps.req_counter == 3
assert ps.register_handler.call_count == 2
handler = list(ps._event_map.values())[1][1]
assert ps.register_handler.call_count == 2 # type: ignore[attr-defined] # pylint: disable=no-member
handler = list(ps._event_map.values())[1][1] # pylint: disable=protected-access
mock_process = mocker.patch.object(handler, 'process', wraps=handler.process)
ps_client.feed([b'\n\x00\x00\x02\xc5\xff\xff\xff\xfe', MSG_BODY_1,
b'\x0e\x00\x00\x023\xff\xff\xff\xfe', MSG_BODY_2])
mock_process = mocker.patch.object(handler, "process", wraps=handler.process)
ps_client.feed(
[
b"\n\x00\x00\x02\xc5\xff\xff\xff\xfe",
MSG_BODY_1,
b"\x0e\x00\x00\x023\xff\xff\xff\xfe",
MSG_BODY_2,
]
)
# execute both message polling and response handling loops
collected, handled = await gather(_collect_messages(ps), _collect_messages(stream_handler))
@ -227,38 +323,45 @@ async def test_message_stream(ps_client, mocker):
for msg in handled:
# responses have negative req
assert msg
assert msg.req == -2
@pytest.mark.asyncio
async def test_message_request(ps_server, mocker):
async def test_message_request(
ps_server: MockSHSServer, mocker: MockerFixture # pylint: disable=redefined-outer-name
) -> None:
"""Test message sending"""
ps_server.listen()
ps = PacketStream(ps_server)
mocker.patch.object(ps, 'register_handler', wraps=ps.register_handler)
mocker.patch.object(ps, "register_handler", wraps=ps.register_handler)
ps.send({
'name': ['whoami'],
'args': []
})
ps.send({"name": ["whoami"], "args": []})
header, body = list(ps_server.get_output())
assert header == b'\x02\x00\x00\x00 \x00\x00\x00\x01'
assert json.loads(body.decode('utf-8')) == {"name": ["whoami"], "args": []}
assert header == b"\x02\x00\x00\x00 \x00\x00\x00\x01"
assert json.loads(body.decode("utf-8")) == {"name": ["whoami"], "args": []}
assert ps.req_counter == 2
assert ps.register_handler.call_count == 1
handler = list(ps._event_map.values())[0][1]
mock_process = mocker.AsyncMock()
assert ps.register_handler.call_count == 1 # type: ignore[attr-defined] # pylint: disable=no-member
handler = list(ps._event_map.values())[0][1] # pylint: disable=protected-access
mock_process = mocker.patch.object(handler, "process")
mocker.patch.object(handler, 'process', mock_process)
ps_server.feed([b'\x02\x00\x00\x00>\xff\xff\xff\xff',
b'{"id":"@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519"}'])
ps_server.feed(
[
b"\x02\x00\x00\x00>\xff\xff\xff\xff",
b'{"id":"@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519"}',
]
)
msg = await ps.read()
assert mock_process.await_count == 1
# responses have negative req
assert msg
assert isinstance(msg.body, dict)
assert msg.req == -1
assert msg.body['id'] == '@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519'
assert msg.body["id"] == "@1+Iwm79DKvVBqYKFkhT6fWRbAVvNNVH4F2BSxwhYmx8=.ed25519"
assert ps.req_counter == 2

View File

@ -1,10 +1,33 @@
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2017 PySSB contributors (see AUTHORS for more details)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Tests for the utility functions"""
from base64 import b64decode
from unittest.mock import mock_open, patch
import pytest
from ssb.util import load_ssb_secret, ConfigException
from ssb.util import ConfigException, load_ssb_secret
CONFIG_FILE = """
## Comments should be supported too
@ -16,21 +39,25 @@ CONFIG_FILE = """
}
"""
CONFIG_FILE_INVALID = CONFIG_FILE.replace('ed25519', 'foo')
CONFIG_FILE_INVALID = CONFIG_FILE.replace("ed25519", "foo")
def test_load_secret():
with patch('ssb.util.open', mock_open(read_data=CONFIG_FILE), create=True):
def test_load_secret() -> None:
"""Test loading the SSB secret from a file"""
with patch("ssb.util.open", mock_open(read_data=CONFIG_FILE), create=True):
secret = load_ssb_secret()
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'
assert secret['id'] == '@rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=.ed25519'
assert bytes(secret['keypair']) == priv_key
assert bytes(secret['keypair'].verify_key) == b64decode('rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=')
assert secret["id"] == "@rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=.ed25519"
assert bytes(secret["keypair"]) == priv_key
assert bytes(secret["keypair"].verify_key) == b64decode("rsYpBIcXsxjQAf0JNes+MHqT2DL+EfopWKAp4rGeEPQ=")
def test_load_exception():
def test_load_exception() -> None:
"""Test configuration loading if there is a problem with the file"""
with pytest.raises(ConfigException):
with patch('ssb.util.open', mock_open(read_data=CONFIG_FILE_INVALID), create=True):
with patch("ssb.util.open", mock_open(read_data=CONFIG_FILE_INVALID), create=True):
load_ssb_secret()