commit 7b884dd4f1c40580bfb070ee5bacd54b2d2d309f Author: timotheyca Date: Sat Nov 27 17:43:43 2021 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12e4ba6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,215 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + + + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + + +/data/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..69f4ba6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,51 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ce0526a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e88afad --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/v6d0auth.iml b/.idea/v6d0auth.iml new file mode 100644 index 0000000..74d515a --- /dev/null +++ b/.idea/v6d0auth.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c62c6a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp~=3.8.1 +PyNaCl~=1.4.0 diff --git a/v6d0auth/__init__.py b/v6d0auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/v6d0auth/app.py b/v6d0auth/app.py new file mode 100644 index 0000000..e346f9d --- /dev/null +++ b/v6d0auth/app.py @@ -0,0 +1,87 @@ +import asyncio.futures + +from nacl.encoding import URLSafeBase64Encoder +from aiohttp import web, http_websocket +from nacl.exceptions import BadSignatureError +from nacl.signing import VerifyKey + +from v6d0auth import certs, cdb + +__all__ = ('get_app',) + + +def define_routes(routes: web.RouteTableDef, loop: asyncio.AbstractEventLoop): + print(certs.vkey.encode(URLSafeBase64Encoder).decode()) + mycdb = cdb.CDB(loop) + loop.create_task(mycdb.job()) + + @routes.get('/') + async def home(_request: web.Request): + return web.Response(body='v6d0auth\n') + + @routes.post('/approve') + async def approve(request: web.Request): + try: + cert = mycdb.approve(await request.read()) + except BadSignatureError: + raise web.HTTPUnauthorized + except KeyError: + raise web.HTTPNotFound + else: + return web.Response(body=cert) + + @routes.post('/push') + async def push(request: web.Request): + try: + timeout = mycdb.push(VerifyKey(await request.read())).timeout + except KeyError: + raise web.HTTPTooManyRequests + else: + return web.Response(body=str(timeout)) + + @routes.post('/pull') + async def pull(request: web.Request): + try: + cert = mycdb.pull(VerifyKey(await request.read())) + except KeyError: + raise web.HTTPNotFound + else: + return web.Response(body=cert) + + @routes.get('/pullws') + async def pullws(request: web.Request): + ws = web.WebSocketResponse() + await ws.prepare(request) + try: + srq = mycdb.requester_mapping[VerifyKey(await ws.receive_bytes())] + except TypeError: + await ws.close(code=http_websocket.WSCloseCode.POLICY_VIOLATION) + else: + try: + cert = await srq.future + except asyncio.CancelledError: + if not srq.future.cancelled(): + srq.future.cancel() + if not srq.cancelled: + srq.future = asyncio.get_event_loop().create_future() + await ws.close(code=http_websocket.WSCloseCode.TRY_AGAIN_LATER) + else: + await ws.send_bytes(cert) + await ws.close() + return ws + + +def app_routes(loop: asyncio.AbstractEventLoop) -> web.RouteTableDef: + routes = web.RouteTableDef() + define_routes(routes, loop) + return routes + + +def app_with_routes(routes: web.RouteTableDef): + app = web.Application() + app.add_routes(routes) + return app + + +def get_app(loop: asyncio.AbstractEventLoop) -> web.Application: + return app_with_routes(app_routes(loop)) diff --git a/v6d0auth/cdb.py b/v6d0auth/cdb.py new file mode 100644 index 0000000..cb70c19 --- /dev/null +++ b/v6d0auth/cdb.py @@ -0,0 +1,93 @@ +import asyncio +import functools +import heapq +import time +import weakref +from typing import MutableMapping, Optional + +from nacl.encoding import URLSafeBase64Encoder +from nacl.signing import VerifyKey +from nacl.utils import random + +from v6d0auth import certs + +__all__ = ('CDB',) + +TIMEOUT = 300 + + +@functools.total_ordering +class SignatureRequest: + def __init__(self, requester: VerifyKey, loop: asyncio.AbstractEventLoop): + self._requester = requester + self.timeout = time.time() + TIMEOUT + self.handle: bytes = random(12) + self.approved: Optional[bytes] = None + self.cancelled = False + self.future: asyncio.Future[bytes] = loop.create_future() + + def __le__(self, other): + if isinstance(other, SignatureRequest): + return self.timeout < other.timeout + else: + return NotImplemented + + def timed_out(self) -> bool: + return time.time() > self.timeout + + def approve(self) -> bytes: + if self.approved is None: + self.approved = certs.sign(bytes(self._requester)) + self.future.set_result(self.approved) + print('approved', self.handle.hex()) + return self.approved + + def cancel(self): + if not self.future.done(): + self.future.cancel() + self.cancelled = True + + +class CDB: + def __init__(self, loop: asyncio.AbstractEventLoop): + self.handle_mapping: MutableMapping[bytes, SignatureRequest] = weakref.WeakValueDictionary() + self.requester_mapping: MutableMapping[VerifyKey, SignatureRequest] = weakref.WeakValueDictionary() + self.heap: list[SignatureRequest] = [] + self._loop = loop + + def _cleanup(self): + while self.heap and self.heap[0].timed_out(): + request = heapq.heappop(self.heap) + request.cancel() + yield request + + def cleanup(self): + if self.heap: + print('cleaning up') + for request in self._cleanup(): + print('cleaned', request.handle.hex()) + + def push(self, requester: VerifyKey) -> SignatureRequest: + if requester in self.requester_mapping: + raise KeyError + request = SignatureRequest(requester, self._loop) + self.requester_mapping[requester] = request + heapq.heappush(self.heap, request) + self.handle_mapping[request.handle] = request + print('requested', request.handle.hex(), requester.encode(URLSafeBase64Encoder).decode()) + return request + + def _approve(self, handle: bytes) -> bytes: + return self.handle_mapping[handle].approve() + + def approve(self, data: bytes) -> bytes: + handle = certs.verify(data) + return self._approve(handle) + + def pull(self, vkey: VerifyKey) -> Optional[bytes]: + return self.requester_mapping[vkey].approved + + async def job(self): + while True: + await asyncio.sleep(TIMEOUT) + self.cleanup() diff --git a/v6d0auth/certs.py b/v6d0auth/certs.py new file mode 100644 index 0000000..44993d2 --- /dev/null +++ b/v6d0auth/certs.py @@ -0,0 +1,35 @@ +from typing import Optional + +from nacl.public import PrivateKey, PublicKey +from nacl.signing import SigningKey, VerifyKey, SignedMessage + +from v6d0auth.config import myroot, cakey + +__all__ = ('vkey', 'pkey',) + +_keyfile = myroot / '.key' +if _keyfile.exists(): + _skey = SigningKey(_keyfile.read_bytes()) +else: + _skey = SigningKey.generate() + _keyfile.write_bytes(bytes(_skey)) +_skey: SigningKey +vkey: VerifyKey = _skey.verify_key +_ekey: PrivateKey = _skey.to_curve25519_private_key() +pkey: PublicKey = _ekey.public_key + + +def sign(data: bytes) -> SignedMessage: + return _skey.sign(data) + + +def verify(data: bytes, signature: Optional[bytes] = None) -> bytes: + return vkey.verify(data, signature) + + +if cakey: + akey: VerifyKey = VerifyKey(cakey) + + + def averify(data: bytes, signature: Optional[bytes] = None) -> bytes: + return akey.verify(data, signature) diff --git a/v6d0auth/client.py b/v6d0auth/client.py new file mode 100644 index 0000000..bbcdc24 --- /dev/null +++ b/v6d0auth/client.py @@ -0,0 +1,18 @@ +import aiohttp + +__all__ = ('request_signature',) + +from v6d0auth import certs + + +async def request_signature(base_url: str) -> bytes: + async with aiohttp.ClientSession() as session: + async with session.post(f'{base_url}/push', data=certs.vkey.encode()) as response: + if response.status not in [200, 429]: + raise RuntimeError + async with session.ws_connect(f'{base_url}/pullws') as ws: + await ws.send_bytes(certs.vkey.encode()) + try: + return await ws.receive_bytes() + except TypeError: + raise TimeoutError diff --git a/v6d0auth/config.py b/v6d0auth/config.py new file mode 100644 index 0000000..c95e723 --- /dev/null +++ b/v6d0auth/config.py @@ -0,0 +1,11 @@ +import os +from pathlib import Path + +__all__ = ('myroot', 'port', 'cakey') + +_root = Path(os.getenv('v6root', './data')) +assert _root.exists() +myroot = _root / 'v6d0auth' +myroot.mkdir(exist_ok=True) +port = int(os.getenv('v6root', '5003')) +cakey = bytes.fromhex(os.getenv('v6ca', '')) diff --git a/v6d0auth/run-server.py b/v6d0auth/run-server.py new file mode 100644 index 0000000..145aed7 --- /dev/null +++ b/v6d0auth/run-server.py @@ -0,0 +1,9 @@ +import asyncio + +from aiohttp import web + +from v6d0auth.app import get_app + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + web.run_app(get_app(loop), host='127.0.0.1', port=5003, loop=loop) diff --git a/v6d0auth/sign-request.py b/v6d0auth/sign-request.py new file mode 100644 index 0000000..f622cfa --- /dev/null +++ b/v6d0auth/sign-request.py @@ -0,0 +1,27 @@ +import argparse +import asyncio + +import aiohttp + +from v6d0auth import certs +from v6d0auth.config import port + +parser = argparse.ArgumentParser() +parser.add_argument('handle', type=str) + + +async def main(): + handle = bytes.fromhex(args.handle) + request = certs.sign(handle) + async with aiohttp.ClientSession() as session: + async with session.post(f'http://127.0.0.1:{port}/approve', data=request) as response: + print(response.status) + if response.status == 200: + print((await response.read()).hex()) + else: + print(await response.text()) + + +if __name__ == '__main__': + args = parser.parse_args() + asyncio.run(main()) diff --git a/v6d0auth/test-request.py b/v6d0auth/test-request.py new file mode 100644 index 0000000..8e67e29 --- /dev/null +++ b/v6d0auth/test-request.py @@ -0,0 +1,12 @@ +import asyncio + +from v6d0auth.client import request_signature +from v6d0auth.config import port + + +async def main(): + print((await request_signature(f'http://127.0.0.1:{port}')).hex()) + + +if __name__ == '__main__': + asyncio.run(main())