diff --git a/.gitignore b/.gitignore index 0ec6dc9..e2bd56f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ **/__pycache__/ .DS_Store .coverage -/*.md \ No newline at end of file +/*.md +/.eggs/ +/devel/ \ No newline at end of file diff --git a/README.md b/README.md index 030d58b..8be40cf 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,40 @@ ![](docs/_assets/images/favpng_parrot-royalty-free-cartoon.png) -httpaste is a pastebin application for easily pasting and retrieving data over -HTTP from shell environments and web browsers. It is inspired by [sprunge.us](http://sprunge.us) -and [ix.io](http://ix.io/), but focuses on extendability, advanced security, with little to -no trade-off to simplicity. It can be hosted through WSGI, CGI, Fast CGI, or -as a standalone evaluation server. It offers multiple storage backends, such as -a filesystem backend, SQLite backend, MySQL backend, or MongoDB backend. +**NOTE**: httpaste is publicly hosted at [httpaste.it](http://httpaste.it) and as a hidden Tor service ([https://paste77ubkwxy4fqezffsmthxdh3xerwi72tlsw2mch7ecjhw2xn7iyd.onion](https://paste77ubkwxy4fqezffsmthxdh3xerwi72tlsw2mch7ecjhw2xn7iyd.onion)). +Both services are to be considered evaluatory, as long as the source code +is in pre-release. Regarding voidance of pre-release status, see [Open Issues](https://victorykit.atlassian.net/issues/?jql=project%20%3D%20HTTPASTE%20AND%20fixVersion%20in%20(1.1.0-beta%2C%201.2.0-beta%2C%201.3.0)), for more information. -All pastes are being encrypted on the fly and can only be retrieved by an -authorized user, either through knowing the paste id of a public paste, or -having authentication credentials, as well as the paste id of a private paste. -This makes httpaste ideal as a pastebin for sensitive environments such as the -Tor network. Authentication credentials are created on-the-fly and don’t require a sign-up process. +This program offers an HTTP interface for storing public and private data +(a.k.a. pastes), commonly referred to as a pastebin application. It is inspired by [sprunge.us](http://sprunge.us) and [ix.io](http://ix.io/). It can be hosted through WSGI, CGI, Fast +CGI, or as a standalone evaluation server. It offers multiple storage backends, +such as a filesystem backend, SQLite backend, or MySQL backend. -httpaste supports output formatting for syntax highlighting (powered by +Public data can be accessed through an URL, where as private pastes +additionally require HTTP basic authentication. Creation of authentication +credentials happens on the fly, there is no sign-up process. Public pastes can +only be accessed by knowing their paste ids, they are not listed on any index, +since it isn’t technically possible (by design). + +All pastes are symetrically encrypted server-side with an HMAC derived key and +SHA-256 hashing, a server-side salt and a randomly generated password. Public +paste’s passwords are derived from their ids. Private paste’s passwords are +randomly generated and stored inside a symetrically encrypted personal +database, with the encryption key also being derived through the same HMAC +mechanism, where the HTTP basic authentication credentials act as the master +password. + +Paste ids, usernames, and any other identifiable attributes are only stored +inside storage backends as keyed and salted BLAKE2 hashes. + +The program supports output formatting for syntax highlighting (powered by [pygments](https://pygments.org/)), as well as MIME type output manipulation, and input encoding. -Therefore httpaste can server as an anonymous object storage for small data. +The program can therefore serve as a minimalist, anonymous object storage for +small data. -Minute-based and ‘burn-after-read’ paste expiration are supported. +Minute-based and ‘burn-after-read’ paste expiration are also supported. -httpaste focuses on security through cryptography, making it a computationally intensive application. - -# Get Started +# Getting Started ## Install diff --git a/docs/README.rst b/docs/README.rst index 0bf034c..96f45f3 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -9,28 +9,41 @@ httpaste - versatile HTTP pastebin .. image:: _assets/images/favpng_parrot-royalty-free-cartoon.png -httpaste is a pastebin application for easily pasting and retrieving data over -HTTP from shell environments and web browsers. It is inspired by `sprunge.us`_ -and `ix.io`_, but focuses on extendability, advanced security, with little to -no trade-off to simplicity. It can be hosted through WSGI, CGI, Fast CGI, or -as a standalone evaluation server. It offers multiple storage backends, such as -a filesystem backend, SQLite backend, MySQL backend, or MongoDB backend. +.. note:: + httpaste is publicly hosted at `httpaste.it`_ and as a hidden Tor service (``_). + Both services are to be considered evaluatory, as long as the source code + is in pre-release. Regarding voidance of pre-release status, see `Open Issues`_, for more information. -All pastes are being encrypted on the fly and can only be retrieved by an -authorized user, either through knowing the paste id of a public paste, or -having authentication credentials, as well as the paste id of a private paste. -This makes httpaste ideal as a pastebin for sensitive environments such as the -Tor network. Authentication credentials are created on-the-fly and don't require a sign-up process. +This program offers an HTTP interface for storing public and private data +(a.k.a. pastes), commonly referred to as a pastebin application. It is inspired by `sprunge.us`_ and `ix.io`_. It can be hosted through WSGI, CGI, Fast +CGI, or as a standalone evaluation server. It offers multiple storage backends, +such as a filesystem backend, SQLite backend, or MySQL backend. -httpaste supports output formatting for syntax highlighting (powered by +Public data can be accessed through an URL, where as private pastes +additionally require HTTP basic authentication. Creation of authentication +credentials happens on the fly, there is no sign-up process. Public pastes can +only be accessed by knowing their paste ids, they are not listed on any index, +since it isn't technically possible (by design). + +All pastes are symetrically encrypted server-side with an HMAC derived key and +SHA-256 hashing, a server-side salt and a randomly generated password. Public +paste's passwords are derived from their ids. Private paste's passwords are +randomly generated and stored inside a symetrically encrypted personal +database, with the encryption key also being derived through the same HMAC +mechanism, where the HTTP basic authentication credentials act as the master +password. + +Paste ids, usernames, and any other identifiable attributes are only stored +inside storage backends as keyed and salted BLAKE2 hashes. + +The program supports output formatting for syntax highlighting (powered by `pygments`_), as well as MIME type output manipulation, and input encoding. -Therefore httpaste can server as an anonymous object storage for small data. +The program can therefore serve as a minimalist, anonymous object storage for +small data. -Minute-based and 'burn-after-read' paste expiration are supported. +Minute-based and 'burn-after-read' paste expiration are also supported. -httpaste focuses on security through cryptography, making it a computationally intensive application. - -.. include:: guide/get-started.rst +.. include:: guide/getting-started.rst Documentation ------------- @@ -69,4 +82,8 @@ This program uses licensed third-party software. .. _ix.io: http://ix.io/ .. _sprunge.us: http://sprunge.us .. _pygments: https://pygments.org/ -.. _icon: https://favpng.com/png_view/parrot-parrot-royalty-free-cartoon-png/gps7HM42 \ No newline at end of file +.. _icon: https://favpng.com/png_view/parrot-parrot-royalty-free-cartoon-png/gps7HM42 + +.. _Open Issues: https://victorykit.atlassian.net/issues/?jql=project%20%3D%20HTTPASTE%20AND%20fixVersion%20in%20(1.1.0-beta%2C%201.2.0-beta%2C%201.3.0) + +.. _httpaste.it: http://httpaste.it \ No newline at end of file diff --git a/docs/guide/get-started.rst b/docs/guide/getting-started.rst similarity index 97% rename from docs/guide/get-started.rst rename to docs/guide/getting-started.rst index 0bbeedc..048f06a 100644 --- a/docs/guide/get-started.rst +++ b/docs/guide/getting-started.rst @@ -1,5 +1,5 @@ -Get Started -=========== +Getting Started +=============== Install """"""" diff --git a/docs/index.rst b/docs/index.rst index bb9e5a0..6b0a60b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ :maxdepth: 1 :caption: Guides - guide/get-started + guide/getting-started guide/advanced-usage guide/backend guide/cli diff --git a/pyproject.toml b/pyproject.toml index 9111d02..cb91714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,8 @@ [build-system] requires = [ "setuptools", - "wheel" + "wheel", + "setuptools-scm[toml]" ] build-backend = "setuptools.build_meta" @@ -9,4 +10,6 @@ build-backend = "setuptools.build_meta" max_line_length = 80 aggressive = 3 recursive = true -in-place = true \ No newline at end of file +in-place = true + +[tool.setuptools_scm] diff --git a/setup.cfg b/setup.cfg index bc3179b..465a26b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,5 @@ [metadata] name = httpaste-victorykit -version = 1.0.0-alpha.1 author = Tiara Rodney author_email = t.rodney@victoryk.it description = a versatile HTTP pastebin @@ -10,10 +9,14 @@ url = https://victorykit.bitbucket.io/httpaste project_urls = Bug Tracker = https://bitbucket.org/victorykit/httpaste/jira classifiers = - topic = : Software Development :: Libraries :: Python Modules - Programming Language :: Python :: 3 + Development Status :: 3 - Alpha + Intended Audience :: Developers + Topic :: Internet :: WWW/HTTP :: WSGI :: Server + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Operating System :: OS Independent - License :: Other/Proprietary License + License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) [options] install_requires = @@ -33,3 +36,8 @@ console_scripts = [options.packages.find] where = src + +[options.package_data] +* = + *.json + *.sql \ No newline at end of file diff --git a/src/httpaste/__init__.py b/src/httpaste/__init__.py index 6eea5ba..14f2385 100755 --- a/src/httpaste/__init__.py +++ b/src/httpaste/__init__.py @@ -143,6 +143,8 @@ from inspect import isclass from configparser import ConfigParser from ast import literal_eval from io import StringIO +from os import environ +from importlib.resources import path as resource_path from connexion import FlaskApp from connexion.resolver import RestyResolver @@ -158,7 +160,7 @@ from httpaste.helper.http import ( UnauthorizedError) -CONFIGPATH_ENVIRON = 'HTTPASTE_CONFIG' +CONFIGPATH_ENVIRON = 'HTTPASTE_CONFIGPATH' def get_sanitized_config_charset(charset: str): @@ -198,17 +200,17 @@ class ServerConfig: bind_address = None -def get_config_path(environ: str = CONFIGPATH_ENVIRON): +def get_config_path(var_name: str = CONFIGPATH_ENVIRON): """ """ try: - return os.environ[environ] + return environ[var_name] except KeyError as e: raise ConfigError( - 'environment variable \'{environ}\' not set.') from e + f'environment variable \'{var_name}\' not set.') from e def load_config(path: str) -> Tuple[Config, ServerConfig]: @@ -300,13 +302,16 @@ def get_flask_app( options = {"swagger_ui": server_config.swagger_ui} - application = FlaskApp(__name__, specification_dir='schema/') + #context manager returns a pathlib.Path object + with resource_path('httpaste.schema', 'httpaste.openapi.json') as path: - application.add_api( - 'httpaste.openapi.json', - options=options, - resolver=RestyResolver('httpaste.controller') - ) + application = FlaskApp(__name__, specification_dir=path.parent) + + application.add_api( + path.name, + options=options, + resolver=RestyResolver('httpaste.controller') + ) for err_cls in [ BadRequestError, @@ -322,6 +327,14 @@ def get_flask_app( with application.app.app_context(): application.app.httpaste = config + #add header for browsers to present a sign-in prompt + @application.app.after_request + def rewrite_forbidden_request(response): + + if response.status_code in [401]: + response.headers['WWW-Authenticate'] = 'Basic realm="private"' + return response + return application diff --git a/src/httpaste/__main__.py b/src/httpaste/__main__.py index 399417f..ec35404 100644 --- a/src/httpaste/__main__.py +++ b/src/httpaste/__main__.py @@ -2,6 +2,7 @@ """ import argparse import os +from importlib.resources import open_text def _this_dir(basename: str) -> str: @@ -20,7 +21,7 @@ def _path_output(path, echo: bool = False) -> str: return path else: - with open(path, 'r') as fh: + with open_text('httpaste', path) as fh: return fh.read() @@ -30,7 +31,14 @@ def command_standalone(**kwargs): """ from httpaste import load_config, get_flask_app - from gevent.pywsgi import WSGIServer + + try: + from gevent.pywsgi import WSGIServer + except ImportError as e: + raise ImportError(' '.join(( + 'gevent is currently not installed.', + 'Please install it by running \'python3 -m pip install gevent\'.' + ))) from e config, server_config = load_config(kwargs.get('config_path')) @@ -44,21 +52,21 @@ def command_wsgi(**kwargs): """get WSGI script """ - print(_path_output(_this_dir('wsgi.py'), kwargs.get('echo'))) + print(_path_output('wsgi.py', kwargs.get('echo'))) def command_cgi(**kwargs): """get CGI script """ - print(_path_output(_this_dir('cgi.py'), kwargs.get('echo'))) + print(_path_output('cgi.py', kwargs.get('echo'))) def command_fcgi(**kwargs): """get FastCGI script """ - print(_path_output(_this_dir('fcgi.py'), kwargs.get('echo'))) + print(_path_output('fcgi.py', kwargs.get('echo'))) def command_default_config(**kwargs): @@ -92,11 +100,25 @@ def command_init_backend(**kwargs): config.backend.paste.init() +def command_sanitize_backend(**kwargs): + """sanitize the backend + """ + + from httpaste import load_config + + config, _ = load_config(kwargs.get('config')) + + config, _ = load_config(kwargs.get('config')) + + config.backend.user.sanitize() + config.backend.paste.sanitize() + + def parser(): p = argparse.ArgumentParser(description='Process some integers.') - sp = p.add_subparsers(dest='command') + sp = p.add_subparsers(dest='command', required=True) p_standalone = sp.add_parser('standalone', help=command_standalone.__doc__) p_standalone.add_argument('--config-path', '-c', required=True) @@ -121,6 +143,11 @@ def parser(): help=command_init_backend.__doc__) p_init_backend.add_argument('--config', '-c', required=True) + p_sanitize_backend = sp.add_parser( + 'sanitize-backend', + help=command_sanitize_backend.__doc__) + p_sanitize_backend.add_argument('--config', '-c', required=True) + return p @@ -136,7 +163,8 @@ def main(): 'cgi': command_cgi, 'fcgi': command_fcgi, 'default-config': command_default_config, - 'init-backend': command_init_backend + 'init-backend': command_init_backend, + 'sanitize-backend': command_sanitize_backend }[kwargs.pop('command')](**kwargs) diff --git a/src/httpaste/backend/__init__.py b/src/httpaste/backend/__init__.py index 2e176d9..48978a0 100644 --- a/src/httpaste/backend/__init__.py +++ b/src/httpaste/backend/__init__.py @@ -26,7 +26,7 @@ class SQLite(Backend): def __init__(self, parameters: SqliteParameters): - parameters['connection'] = get_sqlite_connection(parameters) + parameters = SqliteParameters(parameters.path, get_sqlite_connection(parameters)) self.user = SqliteUser(parameters, User) self.paste = SqlitePaste(parameters, Paste) diff --git a/src/httpaste/backend/file/__init__.py b/src/httpaste/backend/file/__init__.py index a8d1126..9352f8f 100644 --- a/src/httpaste/backend/file/__init__.py +++ b/src/httpaste/backend/file/__init__.py @@ -58,6 +58,13 @@ class User(object): return user.init(self.path) + def sanitize(self): + + if self.path.exists(): + return user.sanitize(self.path, self.model_class, self.model_schema) + + return None + class Paste(object): """Filesystem paste model backend @@ -96,3 +103,10 @@ class Paste(object): def init(self): return paste.init(self.path) + + def sanitize(self): + + if self.path.exists(): + return paste.sanitize(self.path, self.model_class, self.model_schema) + + return None \ No newline at end of file diff --git a/src/httpaste/backend/file/paste.py b/src/httpaste/backend/file/paste.py index d27b7ca..dbeb341 100644 --- a/src/httpaste/backend/file/paste.py +++ b/src/httpaste/backend/file/paste.py @@ -5,6 +5,16 @@ acting as cells. """ from pathlib import Path from ast import literal_eval +from time import time + + +COLUMNS = [ + 'data', + 'data_hash', + 'sub', + 'expiration', + 'encoding' +] def load( @@ -22,13 +32,7 @@ def load( return None cells = {} - for column in [ - 'data', - 'data_hash', - 'sub', - 'timestamp', - 'lifetime', - 'encoding']: + for column in COLUMNS: cell = row.joinpath(column) @@ -56,8 +60,7 @@ def load( cells['sub'], cells['data'], cells['data_hash'], - cells['timestamp'], - cells['lifetime'], + cells['expiration'], cells['encoding']) @@ -68,13 +71,7 @@ def dump(model: object, path: Path, model_schema: type) -> None: row = path.joinpath(model.pid.hex()) row.mkdir(parents=True, exist_ok=True) - for column in [ - 'data', - 'data_hash', - 'sub', - 'timestamp', - 'lifetime', - 'encoding']: + for column in COLUMNS: cell = row.joinpath(column) cell_schema = getattr(model_schema, column) @@ -102,6 +99,22 @@ def init(path: Path): return None +def sanitize(path: Path, model_class: type, model_schema: type): + + for row in path.iterdir(): + + expiration_cell = row.joinpath('expiration') + + if not expiration_cell.exists(): + continue + + expiration = literal_eval(expiration_cell.read_text()) + + if expiration < int(time()) and expiration > 0: + + delete(model_class(bytes.fromhex(row.name)), path) + + def _rm_tree(pth: Path): for child in pth.iterdir(): if child.is_file(): diff --git a/src/httpaste/backend/file/user.py b/src/httpaste/backend/file/user.py index 02255b2..21e32d0 100644 --- a/src/httpaste/backend/file/user.py +++ b/src/httpaste/backend/file/user.py @@ -6,6 +6,12 @@ acting as cells. from pathlib import Path from ast import literal_eval +COLUMNS = [ + 'sub', + 'key_hash', + 'index', +] + def load( proto: object, @@ -22,15 +28,17 @@ def load( return None cells = {} - for column in ['key_hash', 'index']: + for column in COLUMNS[1:]: cell = row.joinpath(column) - if getattr(model_schema, column) == bytes: - + if not cell.exists(): + cells[column] = None + elif getattr(model_schema, column) == bytes: cells[column] = cell.read_bytes() + elif getattr(model_schema, column) == str: + cells[column] = cell.read_text() else: - cells[column] = literal_eval(cell.read_text()) return model_class( @@ -46,7 +54,7 @@ def dump(model: object, path: Path, model_schema: object) -> None: row = path.joinpath(model.sub.hex()) row.mkdir(parents=True, exist_ok=True) - for column in ['key_hash', 'index']: + for column in COLUMNS[1:]: cell = row.joinpath(column) @@ -74,6 +82,11 @@ def init(path: Path): return None +def sanitize(path: Path, model_class: type, model_schema: type): + + return None + + def _rm_tree(pth: Path): for child in pth.iterdir(): if child.is_file(): diff --git a/src/httpaste/backend/sqlite/__init__.py b/src/httpaste/backend/sqlite/__init__.py index 8bd14e5..5680f5c 100644 --- a/src/httpaste/backend/sqlite/__init__.py +++ b/src/httpaste/backend/sqlite/__init__.py @@ -45,6 +45,10 @@ class User(object): return user.init(self.connection) + def sanitize(self): + + return user.sanitize(self.connection, self.model_class) + class Paste(object): """SQLite paste model backend @@ -74,6 +78,10 @@ class Paste(object): return paste.init(self.connection) + def sanitize(self): + + return paste.sanitize(self.connection, self.model_class) + def get_connection(parameters: Parameters): """get an sqlite connection object diff --git a/src/httpaste/backend/sqlite/paste.py b/src/httpaste/backend/sqlite/paste.py index 61f8cd1..51c9fc6 100644 --- a/src/httpaste/backend/sqlite/paste.py +++ b/src/httpaste/backend/sqlite/paste.py @@ -2,6 +2,8 @@ """ from os import path from sqlite3 import Connection +from time import time +from importlib.resources import open_text def load(proto: object, connection: Connection, model_class: type): @@ -11,7 +13,7 @@ def load(proto: object, connection: Connection, model_class: type): cur = connection.cursor() cur.execute( - 'SELECT pid, data, data_hash, sub, timestamp, lifetime, encoding FROM pastes WHERE pid=?', + 'SELECT pid, data, data_hash, sub, expiration, encoding FROM pastes WHERE pid=?', (proto.pid, )) @@ -24,8 +26,7 @@ def load(proto: object, connection: Connection, model_class: type): result['sub'], result['data'], result['data_hash'], - result['timestamp'], - result['lifetime'], + result['expiration'], result['encoding']) return None @@ -38,14 +39,13 @@ def dump(model: object, connection: Connection): cur = connection.cursor() cur.execute( - '''INSERT INTO pastes (pid, data, data_hash, sub, timestamp, lifetime, encoding) - VALUES (?,?,?,?,?,?,?)''', + '''INSERT INTO pastes (pid, data, data_hash, sub, expiration, encoding) + VALUES (?,?,?,?,?,?)''', (model.pid, model.data, model.data_hash, model.sub, - model.timestamp, - model.lifetime, + model.expiration, model.encoding)) connection.commit() @@ -64,8 +64,19 @@ def init(connection: Connection): cur = connection.cursor() - with open(path.join(path.dirname(__file__), 'paste.sql'), 'r') as fh: + with open_text('httpaste.backend.sqlite', 'paste.sql') as fh: cur.execute(fh.read()) connection.commit() + + +def sanitize(connection: Connection, model_class: type) -> bool: + + cur = connection.cursor() + + cur.execute('''SELECT pid FROM pastes WHERE expiration < ? AND expiration > 0''', (int(time()),)) + + for row in cur.fetchall(): + + delete(model_class(row['pid'])) \ No newline at end of file diff --git a/src/httpaste/backend/sqlite/paste.sql b/src/httpaste/backend/sqlite/paste.sql index 5788d5b..7f9bb46 100644 --- a/src/httpaste/backend/sqlite/paste.sql +++ b/src/httpaste/backend/sqlite/paste.sql @@ -1,10 +1,9 @@ CREATE TABLE IF NOT EXISTS "pastes" ( - "id" BLOB NOT NULL UNIQUE, + "pid" BLOB NOT NULL UNIQUE, "data" BLOB NOT NULL, "data_hash" BLOB NOT NULL, "sub" BLOB UNIQUE, - "timestamp" INTEGER NOT NULL, - "lifetime" INTEGER NOT NULL, - "encoding" TEXT - PRIMARY KEY("id") + "expiration" INTEGER NOT NULL, + "encoding" TEXT, + PRIMARY KEY("pid") ); \ No newline at end of file diff --git a/src/httpaste/backend/sqlite/user.py b/src/httpaste/backend/sqlite/user.py index 423b797..e4f712d 100644 --- a/src/httpaste/backend/sqlite/user.py +++ b/src/httpaste/backend/sqlite/user.py @@ -3,6 +3,7 @@ from os import path from sqlite3 import Connection from httpaste.model import User +from importlib.resources import open_text def load(proto: User, connection: Connection): @@ -48,8 +49,13 @@ def init(connection: Connection): cur = connection.cursor() - with open(path.join(path.dirname(__file__), 'user.sql'), 'r') as fh: + with open_text('httpaste.backend.sqlite', 'user.sql') as fh: cur.execute(fh.read()) connection.commit() + + +def sanitize(connection: Connection, model_class) -> bool: + + return None \ No newline at end of file diff --git a/src/httpaste/controller/paste/__init__.py b/src/httpaste/controller/paste/__init__.py index fcd6ab6..c8f4b87 100644 --- a/src/httpaste/controller/paste/__init__.py +++ b/src/httpaste/controller/paste/__init__.py @@ -71,7 +71,7 @@ def get(**kwargs): config.salt, config.hmac_iterations) try: - data, lifetime, encoding = call() + data, expiration, encoding = call() except paste_model.LifetimeError as e: if kwargs.get('user') is not None: paste_model.remove_safe(pid, sub, pkey, config.backend.paste, @@ -85,7 +85,7 @@ def get(**kwargs): raise ForbiddenError(str(e)) # burn after read - if lifetime < 0: + if expiration < 0: if kwargs.get('user') is not None: paste_model.remove_safe(pid, sub, pkey, config.backend.paste, config.salt, config.hmac_iterations) diff --git a/src/httpaste/helper/__init__.py b/src/httpaste/helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/httpaste/helper/common.py b/src/httpaste/helper/common.py index 92cc6d7..2e6d1c7 100644 --- a/src/httpaste/helper/common.py +++ b/src/httpaste/helper/common.py @@ -1,6 +1,10 @@ from random import choice from base64 import b64decode from urllib.parse import urljoin +from tempfile import mkdtemp +from pathlib import Path +from contextlib import contextmanager + class DecodeError(Exception): """ @@ -29,4 +33,4 @@ def decode(data: str, encoding: str) -> bytes: def join_url(base:str, url: str) -> str: - return urljoin(base, url, True) \ No newline at end of file + return urljoin(base, url, True) diff --git a/src/httpaste/model/__init__.py b/src/httpaste/model/__init__.py index 955ded4..7d1280f 100644 --- a/src/httpaste/model/__init__.py +++ b/src/httpaste/model/__init__.py @@ -1,6 +1,6 @@ """Model """ -from typing import NamedTuple, Optional, Dict, Union +from typing import NamedTuple, Optional, Dict, Union, Any, TypedDict class PasteDataSchema: @@ -12,6 +12,7 @@ class PasteDataSchema: sub = bytes timestamp = int lifetime = int + expiration = int encoding = str @@ -54,6 +55,14 @@ class PasteEncoding(PasteDataSchema.encoding): """ +class PasteExpiration(PasteDataSchema.expiration): + """Paste Expiration + + < 0: after first acccess + 0: never + """ + + class PasteLifetime(PasteDataSchema.lifetime): """Paste Lifetime """ @@ -89,9 +98,11 @@ class Sub(UserDataSchema.sub): """ -class Index(Dict[PasteId, PasteKey]): +class Index(TypedDict): """User Paste Index """ + auth_expires: int + pastes: Dict[str, Dict[str, Any]] class SerializedIndex(UserDataSchema.index): @@ -128,8 +139,6 @@ class Paste(NamedTuple): #: paste data hash data_hash: Optional[PasteHash] = None #: paste timestamp - timestamp: Optional[PasteTimestamp] = None - #: paste lifetime - lifetime: Optional[PasteLifetime] = None + expiration: Optional[PasteExpiration] = None #: paste encoding encoding: Optional[PasteEncoding] = None diff --git a/src/httpaste/model/paste.py b/src/httpaste/model/paste.py index 5ad9dc8..f7f856d 100755 --- a/src/httpaste/model/paste.py +++ b/src/httpaste/model/paste.py @@ -10,7 +10,7 @@ from httpaste.helper.crypto import dhash, shash, encrypt, decrypt from httpaste.helper.common import generate_random_string from httpaste.model import (Paste, PasteId, Sub, MasterKey, PasteKey, Salt, PasteData, PasteHash, PasteTimestamp, PasteSub, - PasteLifetime, PasteEncoding) + PasteLifetime, PasteEncoding, PasteExpiration) class NotFoundError(Exception): @@ -87,8 +87,7 @@ def load(proto: Paste, backend: object) -> Optional[Paste]: raise SubError('Paste not owned by user') - if model.lifetime >= 0 and model.timestamp + \ - (60 * model.lifetime) < int(time.time()): + if model.expiration > 0 and model.expiration < int(time.time()): raise LifetimeError('Paste expired') @@ -121,8 +120,7 @@ def load_safe( proto.sub, data, model.data_hash, - model.timestamp, - model.lifetime, + model.expiration, model.encoding) @@ -195,6 +193,11 @@ def create( sub = None timestamp = PasteTimestamp(int(time.time())) + if lifetime < 0: + expiration = -1 + else: + expiration = PasteExpiration(timestamp + (lifetime * 60)) + safe_data = PasteData(encrypt(data, pid, salt, hmac_iter)) model = Paste( @@ -202,8 +205,7 @@ def create( sub, safe_data, data_hash, - timestamp, - lifetime, + expiration, encoding) dump(model, backend) @@ -234,6 +236,11 @@ def create_safe(data: PasteData, safe_sub = PasteSub(shash(sub, data_hash, pid)) timestamp = PasteTimestamp(int(time.time())) + if lifetime < 0: + expiration = -1 + else: + expiration = PasteExpiration(timestamp + (lifetime * 60)) + safe_data = PasteData(encrypt(data, pkey, salt, hmac_iter)) dump(Paste( @@ -241,8 +248,7 @@ def create_safe(data: PasteData, safe_sub, safe_data, data_hash, - timestamp, - lifetime, + expiration, encoding ), backend) @@ -279,7 +285,7 @@ def get(pid: PasteId, backend: object, salt: Salt = Config.salt, hmac_iter: int data = decrypt(model.data, pid, salt, hmac_iter) - return PasteData(data), model.lifetime, model.encoding + return PasteData(data), model.expiration, model.encoding def get_safe( @@ -294,4 +300,4 @@ def get_safe( model = load_safe(Paste(pid, sub), pkey, backend, salt, hmac_iter) - return PasteData(model.data), model.lifetime, model.encoding + return PasteData(model.data), model.expiration, model.encoding diff --git a/src/httpaste/model/user.py b/src/httpaste/model/user.py index 7639127..b925063 100755 --- a/src/httpaste/model/user.py +++ b/src/httpaste/model/user.py @@ -2,6 +2,7 @@ """user model interface """ import json +from time import time from typing import Optional from httpaste import Config @@ -34,7 +35,7 @@ class IndexError(Exception): """ -def load( +def _load( proto: User, master_key: str, backend: object, @@ -54,16 +55,19 @@ def load( return None try: - return User( - *model[:-1], - Index(**json.loads(decrypt(model.index, master_key, salt, hmac_iter))) - ) + serialized_data = decrypt(model.index, master_key, salt, hmac_iter) except DecryptionError as e: - raise IndexError('unable to decrypt user index') from e + else: + data = json.loads(serialized_data) + + return User( + *model[:-1], + Index(**data) + ) -def dump( +def _dump( model: User, key: MasterKey, backend: object, @@ -77,7 +81,7 @@ def dump( :param salt: randomization salt """ - if not isinstance(model.index, Index): + if model.index is not None and not isinstance(model.index, dict): raise BaseException('index serialization pre-processing not allowed.') @@ -102,11 +106,13 @@ def load_paste_key( :param salt: randomization salt """ - for k, v in load(User(sub), key, backend, salt, hmac_iter).index.items(): + model = _load(User(sub), key, backend, salt, hmac_iter) + + for k, v in model.index.get('pastes').items(): if bytes.fromhex(k) == pid: - return PasteKey(bytes.fromhex(v)) + return PasteKey(bytes.fromhex(v.get('key'))) return None @@ -128,12 +134,13 @@ def dump_paste_key( :param backend: user model backend """ - model = load(User(sub), key, backend, salt, hmac_iter) + model = _load(User(sub), key, backend, salt, hmac_iter) - dump(User(*model[:-1], Index({ - **model.index, - **{pid.hex(): pkey.hex()} - })), key, backend, salt, hmac_iter) + model.index.setdefault('pastes', {})[pid.hex()] = { + 'key': pkey.hex() + } + + _dump(model, key, backend, salt, hmac_iter) def authenticate( @@ -154,22 +161,36 @@ def authenticate( proto = User(sub) + bogus_decline_msg = 'unable to authenticate' + try: - model = load(proto, key, backend, salt, hmac_iter) + model = _load(proto, key, backend, salt, hmac_iter) except IndexError as e: - raise AuthenticationError('you dun goofed') + raise AuthenticationError(bogus_decline_msg) from e if not model: - model = User(sub, key_hash, Index({})) - dump(model, key, backend, salt, hmac_iter) + data = { + 'auth_expires': int(time()) + (1 * 60) + } + + model = User(sub, key_hash, Index(data)) + _dump(model, key, backend, salt, hmac_iter) else: if model.key_hash != key_hash: - raise AuthenticationError('you dun goofed') + raise AuthenticationError(bogus_decline_msg) return { 'sub': sub, 'master_key': key } + + +__all__ = [ + AuthenticationError, + load_paste_key, + dump_paste_key, + authenticate +] \ No newline at end of file diff --git a/src/httpaste/schema/__init__.py b/src/httpaste/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/httpaste/schema/httpaste.openapi.json b/src/httpaste/schema/httpaste.openapi.json index 163a57e..8f55586 100644 --- a/src/httpaste/schema/httpaste.openapi.json +++ b/src/httpaste/schema/httpaste.openapi.json @@ -161,6 +161,15 @@ }, { "$ref": "#/components/parameters/syntax" + }, + { + "$ref": "#/components/parameters/format" + }, + { + "$ref": "#/components/parameters/linenos" + }, + { + "$ref": "#/components/parameters/mime" } ], "responses": { diff --git a/src/httpaste/wsgi.py b/src/httpaste/wsgi.py index e45f821..9e43ff0 100755 --- a/src/httpaste/wsgi.py +++ b/src/httpaste/wsgi.py @@ -3,6 +3,6 @@ """ from httpaste import load_config, get_flask_app, get_config_path -config, server_config = load_config(get_config_path) +config, server_config = load_config(get_config_path()) application = get_flask_app(config, server_config)