├── .gitignore ├── data └── .gitignore ├── lockbox ├── debug.py ├── auth.py ├── templating.py ├── config.py ├── flashes.py ├── routes │ ├── main_page.py │ ├── login.py │ ├── delete_key.py │ ├── list_keys.py │ ├── deploy_key.py │ ├── change_password.py │ └── register.py ├── db.py ├── __init__.py └── integrations │ ├── __init__.py │ └── github.py ├── docs └── scrot.png ├── .env.schema ├── run_prod.sh ├── run_dev.py ├── .editorconfig ├── Dockerfile ├── Pipfile ├── migrations ├── script.py.mako ├── versions │ ├── 836f80f48e09_create_access_keys_table.py │ ├── 4997a5924512_create_integrations_table.py │ └── 99e9738f4e73_create_users_and_keys_tables.py └── env.py ├── alembic.ini ├── templates ├── register.html.j2 ├── change_password.html.j2 ├── base.html.j2 └── index.html.j2 ├── requirements.txt ├── contrib └── check_keys.sh ├── static └── global.css ├── README.md └── Pipfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | /.env 3 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /lockbox/debug.py: -------------------------------------------------------------------------------- 1 | from lockbox import app 2 | 3 | app.debug = True 4 | -------------------------------------------------------------------------------- /docs/scrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/char/ssh-lockbox/HEAD/docs/scrot.png -------------------------------------------------------------------------------- /.env.schema: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite:///data/lockbox-dev.db 2 | SESSION_SECRET_KEY= 3 | 4 | OAUTH_BASE_URL= 5 | -------------------------------------------------------------------------------- /run_prod.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | gunicorn "$@" -w ${WORKERS:-4} -k uvicorn.workers.UvicornWorker --log-level warning lockbox:app 4 | -------------------------------------------------------------------------------- /run_dev.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import uvicorn 4 | 5 | if __name__ == "__main__": 6 | uvicorn.run("lockbox.debug:app", host="127.0.0.1", port=5000, reload=True) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = true 9 | 10 | [*.py] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | RUN pip install --no-cache-dir gunicorn 3 | 4 | WORKDIR /app 5 | 6 | COPY requirements.txt ./ 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | COPY . . 10 | RUN alembic upgrade head 11 | CMD [ "bash", "run_prod.sh", "--bind", "0.0.0.0:80" ] 12 | -------------------------------------------------------------------------------- /lockbox/auth.py: -------------------------------------------------------------------------------- 1 | from starlette.authentication import AuthenticationBackend, AuthCredentials, SimpleUser 2 | from starlette.requests import Request 3 | 4 | 5 | class SessionAuthBackend(AuthenticationBackend): 6 | async def authenticate(self, request: Request): 7 | if (username := request.session.get("username")) : 8 | return AuthCredentials(["authenticated"]), SimpleUser(username) 9 | -------------------------------------------------------------------------------- /lockbox/templating.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | from starlette.templating import Jinja2Templates 3 | 4 | from lockbox.flashes import get_and_clear_flashes 5 | 6 | templates = Jinja2Templates(directory="templates") 7 | 8 | 9 | def render_template(request: Request, template_name: str, **kwargs): 10 | context = {"request": request, "flashes": get_and_clear_flashes(request)} 11 | context.update(kwargs) 12 | return templates.TemplateResponse(template_name, context) 13 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "*" 8 | 9 | [packages] 10 | httpx = "*" 11 | uvicorn = "*" 12 | starlette = "*" 13 | jinja2 = "*" 14 | aiofiles = "*" 15 | alembic = "*" 16 | sqlalchemy = "*" 17 | databases = {extras = ["sqlite"], version = "*"} 18 | itsdangerous = "*" 19 | python-multipart = "*" 20 | bcrypt = "*" 21 | 22 | [requires] 23 | python_version = "3.8" 24 | 25 | [pipenv] 26 | allow_prereleases = true 27 | -------------------------------------------------------------------------------- /lockbox/config.py: -------------------------------------------------------------------------------- 1 | from starlette.config import Config 2 | from starlette.middleware.sessions import Secret 3 | 4 | import databases 5 | 6 | config = Config(env_file=".env") 7 | 8 | DATABASE_URL = config("DATABASE_URL", cast=databases.DatabaseURL) 9 | SESSION_SECRET_KEY = config("SESSION_SECRET_KEY", cast=Secret) 10 | REGISTRATION_ENABLED = config("REGISTRATION_ENABLED", cast=bool, default=False) 11 | 12 | OAUTH_BASE_URL = config("OAUTH_BASE_URL") 13 | 14 | GITHUB_CLIENT_ID = config("GITHUB_CLIENT_ID", default=None) 15 | GITHUB_CLIENT_SECRET = config("GITHUB_CLIENT_SECRET", cast=Secret, default=None) 16 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /lockbox/flashes.py: -------------------------------------------------------------------------------- 1 | # Flask-esque flashes for Starlette. Requires session middleware. 2 | 3 | from typing import List, Tuple 4 | 5 | from starlette.requests import Request 6 | 7 | 8 | def flash(request: Request, category: str, message: str): 9 | flashes = request.session.setdefault("flashes", []) 10 | flashes.append({"category": category, "message": message}) 11 | 12 | 13 | def get_and_clear_flashes(request: Request) -> List[Tuple[str, str]]: 14 | flashes = request.session.get("flashes") 15 | request.session["flashes"] = [] 16 | 17 | if flashes is None: 18 | return [] 19 | 20 | return [(flash["category"], flash["message"]) for flash in flashes] 21 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | script_location = migrations 5 | 6 | [loggers] 7 | keys = root,sqlalchemy,alembic 8 | 9 | [handlers] 10 | keys = console 11 | 12 | [formatters] 13 | keys = generic 14 | 15 | [logger_root] 16 | level = WARN 17 | handlers = console 18 | qualname = 19 | 20 | [logger_sqlalchemy] 21 | level = WARN 22 | handlers = 23 | qualname = sqlalchemy.engine 24 | 25 | [logger_alembic] 26 | level = INFO 27 | handlers = 28 | qualname = alembic 29 | 30 | [handler_console] 31 | class = StreamHandler 32 | args = (sys.stderr,) 33 | level = NOTSET 34 | formatter = generic 35 | 36 | [formatter_generic] 37 | format = %(levelname)-5.5s [%(name)s] %(message)s 38 | datefmt = %H:%M:%S 39 | -------------------------------------------------------------------------------- /templates/register.html.j2: -------------------------------------------------------------------------------- 1 | {% set title = "Register" %} 2 | {% set description = "Create an account to use the key deployment system." %} 3 | {% extends "base.html.j2" %} 4 | 5 | {% block content %} 6 |

← Back

7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /templates/change_password.html.j2: -------------------------------------------------------------------------------- 1 | {% set title = "Change Password" %} 2 | {% set description = "Change your password here." %} 3 | {% extends "base.html.j2" %} 4 | 5 | {% block content %} 6 |

← Back

7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /migrations/versions/836f80f48e09_create_access_keys_table.py: -------------------------------------------------------------------------------- 1 | """Create access keys table 2 | 3 | Revision ID: 836f80f48e09 4 | Revises: 99e9738f4e73 5 | Create Date: 2020-07-20 19:17:52.050671 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "836f80f48e09" 14 | down_revision = "99e9738f4e73" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "access_keys", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("user_id", sa.Integer(), nullable=False), 24 | sa.Column("access_key_description", sa.String(), nullable=False), 25 | sa.Column("access_key_token", sa.String(), nullable=False), 26 | sa.ForeignKeyConstraint(["user_id"], ["users.id"],), 27 | sa.PrimaryKeyConstraint("id"), 28 | ) 29 | 30 | 31 | def downgrade(): 32 | op.drop_table("access_keys") 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple/ 2 | aiofiles==0.5.0 3 | aiosqlite==0.15.0 4 | alembic==1.4.2 5 | bcrypt==3.1.7 6 | certifi==2020.6.20 7 | cffi==1.14.1 8 | chardet==3.0.4 9 | click==7.1.2 10 | databases[sqlite]==0.3.2 11 | h11==0.9.0 12 | h2==3.2.0 13 | hpack==3.0.0 14 | hstspreload==2020.7.29 15 | httpcore==0.9.1 16 | httptools==0.1.1 ; sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy' 17 | httpx==0.13.3 18 | hyperframe==5.2.0 19 | idna==2.10 20 | itsdangerous==1.1.0 21 | jinja2==2.11.2 22 | mako==1.1.3 23 | markupsafe==2.0.0a1 24 | pycparser==2.20 25 | python-dateutil==2.8.1 26 | python-editor==1.0.4 27 | python-multipart==0.0.5 28 | rfc3986==1.4.0 29 | six==1.15.0 30 | sniffio==1.1.0 31 | sqlalchemy==1.3.18 32 | starlette==0.13.6 33 | typing-extensions==3.7.4.2 34 | uvicorn==0.11.7 35 | uvloop==0.14.0 ; sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy' 36 | websockets==8.1 37 | -------------------------------------------------------------------------------- /templates/base.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ title }}{% if not override_title %} - Lockbox{% endif %} 9 | 10 | 11 | 12 | {% block head %}{% endblock %} 13 | 14 | 15 | 16 |
17 | {% if flashes %} 18 | 23 | {% endif %} 24 | 25 | {% if not hide_header %} 26 |
27 |

{{ title }}

28 |

{{ description }}

29 |
30 | {% endif %} 31 | 32 | {% block content %}{% endblock %} 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /lockbox/routes/main_page.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | 3 | from lockbox.db import database, users, keys 4 | from lockbox.templating import render_template 5 | from lockbox.config import REGISTRATION_ENABLED 6 | 7 | 8 | async def main_page_endpoint(request: Request): 9 | """Render the main page. Shows either a login form or an SSH key submission form""" 10 | 11 | context = {"registration_enabled": REGISTRATION_ENABLED} 12 | 13 | if request.user.is_authenticated: 14 | query = users.select().where(users.c.username == request.user.username) 15 | user = await database.fetch_one(query) 16 | assert user is not None 17 | 18 | query = keys.select().where(keys.c.user_id == user[0]) 19 | user_ssh_keys = await database.fetch_all(query) 20 | user_ssh_keys = [(key[2], key[3], key[4]) for key in user_ssh_keys] 21 | 22 | context["user_ssh_keys"] = user_ssh_keys 23 | 24 | return render_template(request, "index.html.j2", **context) 25 | -------------------------------------------------------------------------------- /migrations/versions/4997a5924512_create_integrations_table.py: -------------------------------------------------------------------------------- 1 | """Create integrations table 2 | 3 | Revision ID: 4997a5924512 4 | Revises: 836f80f48e09 5 | Create Date: 2020-07-27 03:55:32.781227 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "4997a5924512" 14 | down_revision = "836f80f48e09" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "user_integrations", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("user_id", sa.Integer(), nullable=False), 24 | sa.Column("integration_type", sa.String(), nullable=False), 25 | sa.Column("integration_domain", sa.String(), nullable=False), 26 | sa.Column("integration_data", sa.JSON(), nullable=False), 27 | sa.ForeignKeyConstraint(["user_id"], ["users.id"],), 28 | sa.PrimaryKeyConstraint("id"), 29 | ) 30 | 31 | 32 | def downgrade(): 33 | op.drop_table("user_integrations") 34 | -------------------------------------------------------------------------------- /migrations/versions/99e9738f4e73_create_users_and_keys_tables.py: -------------------------------------------------------------------------------- 1 | """Create users and keys tables 2 | 3 | Revision ID: 99e9738f4e73 4 | Revises: 5 | Create Date: 2020-07-18 01:45:36.201155 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "99e9738f4e73" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "users", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("username", sa.String(), nullable=False), 24 | sa.Column("password_hash", sa.String(), nullable=False), 25 | sa.PrimaryKeyConstraint("id"), 26 | ) 27 | op.create_table( 28 | "keys", 29 | sa.Column("id", sa.Integer(), nullable=False), 30 | sa.Column("user_id", sa.Integer(), nullable=False), 31 | sa.Column("key_algorithm", sa.String(), nullable=False), 32 | sa.Column("key_contents", sa.String(), nullable=False), 33 | sa.Column("key_comment", sa.String(), nullable=False), 34 | sa.ForeignKeyConstraint(["user_id"], ["users.id"],), 35 | sa.PrimaryKeyConstraint("id"), 36 | ) 37 | 38 | 39 | def downgrade(): 40 | op.drop_table("keys") 41 | op.drop_table("users") 42 | -------------------------------------------------------------------------------- /contrib/check_keys.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # AuthorizedKeysCommand check_keys.sh "%u" 3 | 4 | LOCKBOX_CONFIG_FILE="~$1/.config/lockbox.conf" 5 | eval LOCKBOX_CONFIG_FILE="$LOCKBOX_CONFIG_FILE" 6 | 7 | # If the user doesn't have a lockbox configuration, we shouldn't do anything 8 | if [ -f "$LOCKBOX_CONFIG_FILE" ]; then 9 | { read -r keys_host && read -r keys_user; read -r keys_access; } < "$LOCKBOX_CONFIG_FILE" 10 | 11 | if [ -z "$keys_access" ]; then 12 | fetched_keys=`curl -s "$keys_host/keys/$keys_user"` 13 | else 14 | fetched_keys=`curl -s -H "Authorization: Bearer $keys_access" "$keys_host/keys/$keys_user"` 15 | fi 16 | 17 | if [ $? = 0 ]; then 18 | echo "$fetched_keys" 19 | 20 | authorized_keys_file="~$keys_user/.ssh/authorized_keys" 21 | eval authorized_keys_file="$authorized_keys_file" 22 | if [ -f "$authorized_keys_file" ]; then 23 | regular_authorized_keys=`sed '/.*### LOCKBOX SECTION ###.*/{s///;q;}' < "$authorized_keys_file"` 24 | 25 | (echo "$regular_authorized_keys"; echo '### LOCKBOX SECTION ### 26 | # Please do not edit under this section. It is automatically generated, 27 | # and may be wiped at any time.'; echo "$fetched_keys") > "$authorized_keys_file" 28 | fi 29 | else 30 | >&2 echo "An error occurred while fetching $1's keys from $keys_host." 31 | fi 32 | fi 33 | -------------------------------------------------------------------------------- /lockbox/routes/login.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | 3 | from starlette.requests import Request 4 | from starlette.responses import RedirectResponse 5 | 6 | from lockbox.db import database, users 7 | from lockbox.flashes import flash 8 | 9 | 10 | async def login_valid(username: str, password: str) -> bool: 11 | query = users.select().where(users.c.username == username) 12 | matching_user = await database.fetch_one(query=query) 13 | 14 | if matching_user: 15 | user_id, user_username, user_password_hash = matching_user 16 | 17 | return bcrypt.checkpw( 18 | password.encode("utf-8"), user_password_hash.encode("ascii") 19 | ) 20 | 21 | return False 22 | 23 | 24 | async def login_endpoint(request: Request): 25 | """Process a login form submission (via POST request only)""" 26 | form_data = await request.form() 27 | username = form_data.get("username") 28 | password = form_data.get("password") 29 | 30 | if (username and password) and await login_valid(username, password): 31 | request.session["username"] = username 32 | flash(request, "success", "Logged in.") 33 | else: 34 | flash( 35 | request, 36 | "error", 37 | "The username and password specified not match a user. Please try again.", 38 | ) 39 | 40 | return RedirectResponse("/", 303) 41 | 42 | 43 | async def logout_endpoint(request: Request): 44 | """Process a logout request (via POST request only)""" 45 | del request.session["username"] 46 | flash(request, "success", "Logged out.") 47 | return RedirectResponse("/", 303) 48 | -------------------------------------------------------------------------------- /lockbox/routes/delete_key.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | from starlette.responses import RedirectResponse 3 | from starlette.background import BackgroundTask 4 | 5 | from lockbox.flashes import flash 6 | from lockbox.db import users, keys, database 7 | from lockbox.integrations import run_key_delete_integrations 8 | 9 | 10 | async def delete_key_endpoint(request: Request): 11 | """Delete a key for the logged in user via its comment""" 12 | if not request.user.is_authenticated: 13 | flash(request, "error", "You are not logged in!") 14 | return RedirectResponse("/", 303) 15 | 16 | form_data = await request.form() 17 | key_comment = form_data.get("key_comment") 18 | if not key_comment: 19 | flash(request, "error", "key_comment is a required field.") 20 | return RedirectResponse("/", 303) 21 | 22 | query = users.select().where(users.c.username == request.user.username) 23 | user_id = await database.fetch_val(query) 24 | 25 | background_task = None 26 | 27 | async with database.transaction(): 28 | flash(request, "success", "Deleted key.") 29 | 30 | query = ( 31 | keys.select() 32 | .where(keys.c.user_id == user_id) 33 | .where(keys.c.key_comment == key_comment) 34 | ) 35 | key = await database.fetch_one(query) 36 | 37 | ssh_algo, ssh_contents, ssh_comment = key[2:] 38 | 39 | query = ( 40 | keys.delete() 41 | .where(keys.c.user_id == user_id) 42 | .where(keys.c.key_comment == key_comment) 43 | ) 44 | await database.execute(query) 45 | 46 | background_task = BackgroundTask( 47 | run_key_delete_integrations, user_id, ssh_algo, ssh_contents, ssh_comment 48 | ) 49 | 50 | return RedirectResponse("/", 303, background=background_task) 51 | -------------------------------------------------------------------------------- /lockbox/db.py: -------------------------------------------------------------------------------- 1 | from lockbox.config import DATABASE_URL 2 | 3 | import databases 4 | import sqlalchemy 5 | 6 | database = databases.Database(DATABASE_URL) 7 | 8 | metadata = sqlalchemy.MetaData() 9 | users = sqlalchemy.Table( 10 | "users", 11 | metadata, 12 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 13 | sqlalchemy.Column("username", sqlalchemy.String, nullable=False), 14 | sqlalchemy.Column("password_hash", sqlalchemy.String, nullable=False), 15 | ) 16 | 17 | keys = sqlalchemy.Table( 18 | "keys", 19 | metadata, 20 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 21 | sqlalchemy.Column( 22 | "user_id", sqlalchemy.Integer, sqlalchemy.ForeignKey("users.id"), nullable=False 23 | ), 24 | sqlalchemy.Column("key_algorithm", sqlalchemy.String, nullable=False), 25 | sqlalchemy.Column("key_contents", sqlalchemy.String, nullable=False), 26 | sqlalchemy.Column("key_comment", sqlalchemy.String, nullable=False), 27 | ) 28 | 29 | access_keys = sqlalchemy.Table( 30 | "access_keys", 31 | metadata, 32 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 33 | sqlalchemy.Column( 34 | "user_id", sqlalchemy.Integer, sqlalchemy.ForeignKey("users.id"), nullable=False 35 | ), 36 | sqlalchemy.Column("access_key_description", sqlalchemy.String, nullable=False), 37 | sqlalchemy.Column("access_key_token", sqlalchemy.String, nullable=False), 38 | ) 39 | 40 | user_integrations = sqlalchemy.Table( 41 | "user_integrations", 42 | metadata, 43 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 44 | sqlalchemy.Column( 45 | "user_id", sqlalchemy.Integer, sqlalchemy.ForeignKey("users.id"), nullable=False 46 | ), 47 | sqlalchemy.Column("integration_type", sqlalchemy.String, nullable=False), 48 | sqlalchemy.Column("integration_domain", sqlalchemy.String, nullable=False), 49 | sqlalchemy.Column("integration_data", sqlalchemy.JSON, nullable=False), 50 | ) 51 | -------------------------------------------------------------------------------- /lockbox/routes/list_keys.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from starlette.requests import Request 4 | from starlette.responses import PlainTextResponse 5 | 6 | from lockbox.db import database, keys, users, access_keys 7 | 8 | 9 | def generate_key_info(ssh_keys, include_comments: bool) -> Generator[str, None, None]: 10 | for ssh_key in ssh_keys: 11 | key_id, user_id, key_algo, key_contents, key_comment = ssh_key 12 | yield ( 13 | f"{key_algo} {key_contents} {key_comment}" 14 | if include_comments 15 | else f"{key_algo} {key_contents}" 16 | ) 17 | 18 | 19 | async def access_key_matches(user_id): 20 | query = access_keys.select().where(access_keys.c.user_id == user_id) 21 | async for row in database.iterate(query): 22 | access_key_token = row[3] 23 | 24 | if auth_header_parts[1] == access_key_token: 25 | return True 26 | 27 | return False 28 | 29 | 30 | async def list_keys_endpoint(request: Request): 31 | """Serve a list of keys for a user. 32 | 33 | We include the comment fields if and only if authentication is provided. 34 | """ 35 | 36 | query = users.select().where(users.c.username == request.path_params["user"]) 37 | user = await database.fetch_one(query) 38 | if not user: 39 | return PlainTextResponse("User does not exist.", 404) 40 | 41 | query = keys.select().where(keys.c.user_id == user[0]) 42 | ssh_keys = await database.fetch_all(query) 43 | 44 | include_comments = ( 45 | request.user.is_authenticated and request.user.username == user[1] 46 | ) 47 | 48 | if (authorization_header := request.headers.get("Authorization")) : 49 | auth_header_parts = authorization_header.split() 50 | if len(auth_header_parts) > 1 and auth_header_parts[0].casefold() == "bearer": 51 | if await access_key_matches(user[0], auth_header_parts[1]): 52 | include_comments = True 53 | 54 | return PlainTextResponse("\n".join(generate_key_info(ssh_keys, include_comments))) 55 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | config = context.config 9 | fileConfig(config.config_file_name) 10 | 11 | import sys, os 12 | 13 | # Put the working directory in the sys path so that we can import the lockbox package 14 | sys.path.insert(0, os.path.abspath(".")) 15 | 16 | from lockbox.db import metadata as app_metadata 17 | from lockbox.config import DATABASE_URL 18 | 19 | config.set_main_option("sqlalchemy.url", str(DATABASE_URL)) 20 | target_metadata = app_metadata 21 | 22 | 23 | def run_migrations_offline(): 24 | """Run migrations in 'offline' mode. 25 | 26 | This configures the context with just a URL 27 | and not an Engine, though an Engine is acceptable 28 | here as well. By skipping the Engine creation 29 | we don't even need a DBAPI to be available. 30 | 31 | Calls to context.execute() here emit the given string to the 32 | script output. 33 | 34 | """ 35 | url = config.get_main_option("sqlalchemy.url") 36 | context.configure( 37 | url=url, 38 | target_metadata=target_metadata, 39 | literal_binds=True, 40 | dialect_opts={"paramstyle": "named"}, 41 | ) 42 | 43 | with context.begin_transaction(): 44 | context.run_migrations() 45 | 46 | 47 | def run_migrations_online(): 48 | """Run migrations in 'online' mode. 49 | 50 | In this scenario we need to create an Engine 51 | and associate a connection with the context. 52 | 53 | """ 54 | connectable = engine_from_config( 55 | config.get_section(config.config_ini_section), 56 | prefix="sqlalchemy.", 57 | poolclass=pool.NullPool, 58 | ) 59 | 60 | with connectable.connect() as connection: 61 | context.configure(connection=connection, target_metadata=target_metadata) 62 | 63 | with context.begin_transaction(): 64 | context.run_migrations() 65 | 66 | 67 | if context.is_offline_mode(): 68 | run_migrations_offline() 69 | else: 70 | run_migrations_online() 71 | -------------------------------------------------------------------------------- /templates/index.html.j2: -------------------------------------------------------------------------------- 1 | {% set override_title = true %} 2 | {% set title = "Lockbox" %} 3 | {% set description = "A centralised location for your personal SSH keys." %} 4 | {% extends "base.html.j2" %} 5 | 6 | {% block content %} 7 | {% if request.user.is_authenticated %} 8 |

Logged in as {{ request.user.display_name }}.

9 |
10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 | 21 |
22 | 23 | {% if user_ssh_keys %} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for (algo, contents, comment) in user_ssh_keys %} 33 | 34 | 35 | 36 | 42 | 43 | {% endfor %} 44 | 45 |
CommentDetailsActions
{{ comment }}{{ algo }} 37 |
38 | 39 | 40 |
41 |
46 | {% endif %} 47 | {% else %} 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | {% if registration_enabled %} 59 |

Don't have an account? Sign up!

60 | {% endif %} 61 | {% endif %} 62 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /lockbox/routes/deploy_key.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Optional 2 | 3 | from starlette.requests import Request 4 | from starlette.responses import RedirectResponse 5 | from starlette.background import BackgroundTask 6 | 7 | from lockbox.flashes import flash 8 | from lockbox.db import database, keys, users 9 | from lockbox.integrations import run_key_deploy_integrations 10 | 11 | 12 | class InvalidKeyException(Exception): 13 | """Invalid key - something is wrong with the key.""" 14 | 15 | 16 | def parse_ssh_key(plaintext_key: str) -> Tuple[str, str, Optional[str]]: 17 | key_parts = plaintext_key.split(None, 2) 18 | if len(key_parts) < 2: 19 | raise InvalidKeyException( 20 | "Unexpected key format: type and base64 encoded public key data is required" 21 | ) 22 | 23 | comment = None 24 | if len(key_parts) == 3: 25 | comment = key_parts[2] 26 | 27 | return key_parts[0], key_parts[1], comment 28 | 29 | 30 | async def deploy_key_endpoint(request: Request): 31 | if not request.user.is_authenticated: 32 | flash(request, "error", "Cannot deploy key: You are not logged in.") 33 | return RedirectResponse("/", 303) 34 | 35 | form_data = await request.form() 36 | 37 | plaintext_key = form_data.get("key") 38 | if not plaintext_key: 39 | flash(request, "error", "'key' is a required field.") 40 | return RedirectResponse("/", 303) 41 | plaintext_key = plaintext_key.strip() 42 | 43 | background_task = None 44 | 45 | try: 46 | ssh_algo, ssh_contents, ssh_comment = parse_ssh_key(plaintext_key) 47 | async with database.transaction(): 48 | query = users.select().where(users.c.username == request.user.username) 49 | user_id = await database.fetch_val(query) 50 | 51 | query = keys.insert().values( 52 | user_id=user_id, 53 | key_algorithm=ssh_algo, 54 | key_contents=ssh_contents, 55 | key_comment=ssh_comment, 56 | ) 57 | await database.execute(query) 58 | 59 | flash(request, "success", "The provided SSH key has been deployed.") 60 | 61 | background_task = BackgroundTask( 62 | run_key_deploy_integrations, 63 | user_id, 64 | ssh_algo, 65 | ssh_contents, 66 | ssh_comment, 67 | ) 68 | except: 69 | flash(request, "error", "Invalid key data.") 70 | 71 | return RedirectResponse("/", 303, background=background_task) 72 | -------------------------------------------------------------------------------- /lockbox/__init__.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.routing import Route, Mount 3 | 4 | from starlette.requests import Request 5 | 6 | from starlette.middleware import Middleware 7 | from starlette.middleware.sessions import SessionMiddleware 8 | from starlette.middleware.authentication import AuthenticationMiddleware 9 | 10 | from starlette.staticfiles import StaticFiles 11 | 12 | from lockbox.db import database 13 | from lockbox.config import SESSION_SECRET_KEY 14 | from lockbox.auth import SessionAuthBackend 15 | 16 | from lockbox.routes.main_page import main_page_endpoint 17 | from lockbox.routes.login import login_endpoint, logout_endpoint 18 | from lockbox.routes.register import register_page_endpoint, register_endpoint 19 | from lockbox.routes.change_password import change_password_page_endpoint, change_password_endpoint 20 | from lockbox.routes.deploy_key import deploy_key_endpoint 21 | from lockbox.routes.list_keys import list_keys_endpoint 22 | from lockbox.routes.delete_key import delete_key_endpoint 23 | 24 | from lockbox.integrations.github import ( 25 | initiate_github_integration, 26 | complete_github_integration, 27 | force_sync_github_integration, 28 | ) 29 | 30 | app = Starlette( 31 | routes=[ 32 | Route("/", endpoint=main_page_endpoint), 33 | Route("/login", endpoint=login_endpoint, methods=["POST"]), 34 | Route("/logout", endpoint=logout_endpoint, methods=["POST"]), 35 | Route("/register/", endpoint=register_page_endpoint), 36 | Route("/register", endpoint=register_endpoint, methods=["POST"]), 37 | Route("/deploy", endpoint=deploy_key_endpoint, methods=["POST"]), 38 | Route("/change_password/", endpoint=change_password_page_endpoint), 39 | Route("/change_password", endpoint=change_password_endpoint, methods=["POST"]), 40 | Route("/keys/{user}", endpoint=list_keys_endpoint), 41 | Route("/delete_key", endpoint=delete_key_endpoint, methods=["POST"]), 42 | Mount("/static", app=StaticFiles(directory="static"), name="static"), 43 | Route("/integrations/github/initiate", endpoint=initiate_github_integration), 44 | Route("/integrations/github/complete", endpoint=complete_github_integration), 45 | Route( 46 | "/integrations/github/force_sync", endpoint=force_sync_github_integration 47 | ), 48 | ], 49 | middleware=[ 50 | Middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY), 51 | Middleware(AuthenticationMiddleware, backend=SessionAuthBackend()), 52 | ], 53 | on_startup=[database.connect], 54 | on_shutdown=[database.disconnect], 55 | ) 56 | -------------------------------------------------------------------------------- /lockbox/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from lockbox.db import database, user_integrations 4 | 5 | # TODO: What logic can we re-use from the GitHub integration for other services? 6 | 7 | 8 | class ThirdPartyIntegration: 9 | def __init__(self, user_id: int, integration_domain: str, integration_data: dict): 10 | self.user_id = user_id 11 | self.integration_domain = integration_domain 12 | self.integration_data = integration_data 13 | 14 | async def aclose(self): 15 | pass 16 | 17 | async def full_sync(self): 18 | raise NotImplementedError( 19 | f"'full_sync' is not implemented for {self.__class__.__name__}" 20 | ) 21 | 22 | async def on_new_key(self, key_algorithm, key_contents, key_comment): 23 | raise NotImplementedError( 24 | f"'on_new_key' is not implemented for {self.__class__.__name__}" 25 | ) 26 | 27 | async def on_delete_key(self, key_algorithm, key_contents, key_comment): 28 | raise NotImplementedError( 29 | f"'on_delete_key' is not implemented for {self.__class__.__name__}" 30 | ) 31 | 32 | async def __aenter__(self): 33 | return self 34 | 35 | async def __aexit__(self, exc_type=None, exc_value=None, traceback=None): 36 | await self.aclose() 37 | 38 | 39 | integrations = {} 40 | 41 | 42 | def get_integration_from_db(user_integration_row): 43 | ( 44 | user_id, 45 | integration_type, 46 | integration_domain, 47 | integration_data, 48 | ) = user_integration_row[1:] 49 | 50 | integration_class = integrations.get(integration_type) 51 | if not integration_class: 52 | return None 53 | 54 | return integration_class(user_id, integration_domain, integration_data) 55 | 56 | 57 | async def get_integrations_for_user(user_id: int): 58 | query = user_integrations.select().where(user_integrations.c.user_id == user_id) 59 | async for user_integration in database.iterate(query): 60 | async with get_integration_from_db(user_integration) as integration: 61 | yield integration 62 | 63 | 64 | async def run_key_deploy_integrations( 65 | user_id: int, key_algorithm: str, key_contents: str, key_comment: str 66 | ): 67 | async for integration in get_integrations_for_user(user_id): 68 | await integration.on_new_key(key_algorithm, key_contents, key_comment) 69 | 70 | 71 | async def run_key_delete_integrations( 72 | user_id: int, key_algorithm: str, key_contents: str, key_comment: str 73 | ): 74 | async for integration in get_integrations_for_user(user_id): 75 | await integration.on_delete_key(key_algorithm, key_contents, key_comment) 76 | 77 | 78 | from lockbox.integrations.github import GitHubIntegration 79 | 80 | integrations["GitHub"] = GitHubIntegration 81 | -------------------------------------------------------------------------------- /lockbox/routes/change_password.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | 3 | from starlette.requests import Request 4 | from starlette.responses import RedirectResponse, PlainTextResponse 5 | 6 | from lockbox.templating import render_template 7 | from lockbox.db import database, users 8 | from lockbox.config import REGISTRATION_ENABLED 9 | from lockbox.flashes import flash 10 | 11 | 12 | async def change_password_page_endpoint(request: Request): 13 | """Render the change password form""" 14 | 15 | if not request.user.is_authenticated: 16 | flash(request, "error", "You are not logged in.") 17 | return RedirectResponse("/", 303) 18 | 19 | return render_template(request, "change_password.html.j2") 20 | 21 | 22 | async def change_password_endpoint(request: Request): 23 | """Process a change password form submission (via POST request only)""" 24 | 25 | if not request.user.is_authenticated: 26 | flash(request, "error", "You are not logged in.") 27 | return RedirectResponse("/", 303) 28 | 29 | query = users.select().where(users.c.username == request.user.username) 30 | user = await database.fetch_one(query=query) 31 | user_id, user_username, user_password_hash = user 32 | 33 | form_data = await request.form() 34 | current_password = form_data.get("current_password") 35 | password = form_data.get("password") 36 | password_confirm = form_data.get("password_confirm") 37 | 38 | has_errors = False 39 | 40 | if not current_password: 41 | has_errors = True 42 | flash(request, "error", "'current_password' is a required field.") 43 | 44 | if not password: 45 | has_errors = True 46 | flash(request, "error", "'password' is a required field.") 47 | 48 | if not password_confirm: 49 | has_errors = True 50 | flash(request, "error", "'password_confirm' is a required field.") 51 | 52 | if not bcrypt.checkpw( 53 | current_password.encode("utf-8"), user_password_hash.encode("ascii") 54 | ): 55 | has_errors = True 56 | flash(request, "error", "The provided password confirmation is incorrect.") 57 | 58 | if len(password) < 32: 59 | has_errors = True 60 | flash( 61 | request, 62 | "error", 63 | "The provided password is too short. It must be at least 32 characters in length.", 64 | ) 65 | 66 | if password != password_confirm: 67 | has_errors = True 68 | flash( 69 | request, 70 | "error", 71 | "The provided password does not match the password confirmation.", 72 | ) 73 | 74 | if has_errors: 75 | return RedirectResponse("/change_password/", 303) 76 | 77 | password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) 78 | password_hash = password_hash.decode("ascii") 79 | 80 | async with database.transaction(): 81 | update_query = ( 82 | users.update() 83 | .where(users.c.username == request.user.username) 84 | .values(password_hash=password_hash) 85 | ) 86 | 87 | await database.execute(update_query) 88 | 89 | flash(request, "success", "Password changed.") 90 | 91 | return RedirectResponse("/", 303) 92 | -------------------------------------------------------------------------------- /lockbox/routes/register.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | 3 | from starlette.requests import Request 4 | from starlette.responses import RedirectResponse, PlainTextResponse 5 | 6 | from lockbox.templating import render_template 7 | from lockbox.db import database, users 8 | from lockbox.config import REGISTRATION_ENABLED 9 | from lockbox.flashes import flash 10 | 11 | 12 | async def disabled_registration_endpoint(request: Request): 13 | """Return a plain text response for when registration is disabled. 14 | 15 | This is used for both endpoints: the page at /register/ and the form action at /register. 16 | """ 17 | return PlainTextResponse("Registration is currently disabled.") 18 | 19 | 20 | async def real_register_page_endpoint(request: Request): 21 | """Render the registration form""" 22 | if request.user.is_authenticated: 23 | return RedirectResponse("/", 303) 24 | 25 | return render_template(request, "register.html.j2") 26 | 27 | 28 | async def user_already_exists(username: str) -> bool: 29 | query = users.select(users.c.username == username) 30 | return (await database.fetch_one(query)) is not None 31 | 32 | 33 | async def real_register_endpoint(request: Request): 34 | """Process a registration form submission (via POST request only)""" 35 | form_data = await request.form() 36 | 37 | username = form_data.get("username") 38 | password = form_data.get("password") 39 | password_confirm = form_data.get("password_confirm") 40 | 41 | has_errors = False 42 | 43 | if not username: 44 | has_errors = True 45 | flash(request, "error", "'username' is a required field.") 46 | 47 | if not password: 48 | has_errors = True 49 | flash(request, "error", "'password' is a required field.") 50 | 51 | if not password_confirm: 52 | has_errors = True 53 | flash(request, "error", "'password_confirm' is a required field.") 54 | 55 | if await user_already_exists(username): 56 | has_errors = True 57 | flash(request, "error", "This username is already taken!") 58 | 59 | if len(password) < 32: 60 | has_errors = True 61 | flash( 62 | request, 63 | "error", 64 | "The provided password is too short. It must be at least 32 characters in length.", 65 | ) 66 | 67 | if password != password_confirm: 68 | has_errors = True 69 | flash( 70 | request, 71 | "error", 72 | "The provided password does not match the password confirmation.", 73 | ) 74 | 75 | if not has_errors: 76 | flash(request, "success", "Successfully registered. Please log in.") 77 | async with database.transaction(): 78 | password_hash = bcrypt.hashpw( 79 | password.encode("utf-8"), bcrypt.gensalt() 80 | ).decode("ascii") 81 | query = users.insert().values( 82 | username=username, password_hash=password_hash 83 | ) 84 | await database.execute(query) 85 | 86 | return RedirectResponse("/register/" if has_errors else "/", 303) 87 | 88 | 89 | register_page_endpoint = ( 90 | real_register_page_endpoint 91 | if REGISTRATION_ENABLED 92 | else disabled_registration_endpoint 93 | ) 94 | 95 | register_endpoint = ( 96 | real_register_endpoint if REGISTRATION_ENABLED else disabled_registration_endpoint 97 | ) 98 | -------------------------------------------------------------------------------- /static/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 2em; 3 | font-family: sans-serif; 4 | } 5 | 6 | main { 7 | max-width: 60ch; 8 | margin: 0 auto; 9 | } 10 | 11 | header { 12 | border-bottom: 1px solid black; 13 | margin-bottom: 1.5em; 14 | } 15 | 16 | .form { 17 | display: flex; 18 | flex-direction: column; 19 | 20 | margin-top: 2em; 21 | } 22 | 23 | .small-form { 24 | display: inline-block; 25 | margin-top: -0.5em; 26 | } 27 | 28 | textarea { 29 | margin-bottom: 1em !important; 30 | } 31 | 32 | label { 33 | font-weight: 500; 34 | } 35 | 36 | input[type="text"], 37 | input[type="password"], 38 | textarea { 39 | background: #fff; 40 | background-clip: padding-box; 41 | border: 1px solid rgba(0, 0, 0, .12); 42 | border-radius: 6px; 43 | color: rgba(0, 0, 0, .8); 44 | display: block; 45 | 46 | margin: 0.5em 0.25em; 47 | padding: 0.5em 1.0em; 48 | line-height: 1.5; 49 | 50 | transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; 51 | font-family: sans-serif; 52 | 53 | resize: none; 54 | overflow-x: wrap; 55 | overflow-y: scroll; 56 | } 57 | 58 | input[type="text"]:focus, 59 | input[type="password"]:focus, 60 | textarea:focus { 61 | background-color: #fff; 62 | border-color: #80bdff; 63 | outline: 0; 64 | box-shadow: 0 0 0 .2rem rgba(0, 123, 255, .25); 65 | } 66 | 67 | input[type="submit"] { 68 | background-color: rgb(21, 124, 214); 69 | border: rgb(2, 97, 180); 70 | border-radius: 6px; 71 | color: #fff; 72 | padding: 0.5em 1em; 73 | display: inline-block; 74 | font-weight: 400; 75 | text-align: center; 76 | white-space: nowrap; 77 | vertical-align: middle; 78 | 79 | user-select: none; 80 | border: 1px solid rgba(0, 0, 0, 0); 81 | font-size: 1rem; 82 | line-height: 1.5; 83 | 84 | transition: color .15s ease-in-out, 85 | background-color .15s ease-in-out, 86 | border-color .15s ease-in-out, 87 | box-shadow .15s ease-in-out; 88 | } 89 | 90 | input[type="submit"]:active { 91 | background-color: rgb(47, 123, 190); 92 | border-color: rgb(23, 78, 126); 93 | color: #ffffff; 94 | } 95 | 96 | input+input[type="submit"] { 97 | margin-top: 0.5em; 98 | } 99 | 100 | input[type="submit"].danger { 101 | background-color: rgb(204, 41, 41); 102 | } 103 | 104 | input[type="submit"]:active.danger { 105 | background-color: rgb(160, 41, 41); 106 | border-color: rgb(155, 26, 26); 107 | } 108 | 109 | .keys-table { 110 | margin-top: 4em; 111 | width: 100%; 112 | } 113 | 114 | thead th { 115 | border: 0; 116 | border-bottom: 2px solid rgba(0, 0, 0, .12); 117 | text-align: left; 118 | } 119 | 120 | tr { 121 | margin-bottom: 16px; 122 | } 123 | 124 | th, 125 | td { 126 | border-bottom: 1px solid rgba(0, 0, 0, .12); 127 | padding: 16px 0; 128 | vertical-align: inherit; 129 | } 130 | 131 | .flashes { 132 | display: block; 133 | position: fixed; 134 | 135 | right: 1em; 136 | bottom: 1em; 137 | 138 | margin: 0; 139 | padding: 0; 140 | } 141 | 142 | .flash { 143 | list-style: none; 144 | 145 | padding: 1em; 146 | border-radius: 6px; 147 | 148 | background-color: #333; 149 | color: #fff; 150 | } 151 | 152 | .flash+.flash { 153 | margin-top: .5em; 154 | } 155 | 156 | .flash-error { 157 | background-color: #a33; 158 | } 159 | 160 | .flash-success { 161 | background-color: #27b; 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lockbox 2 | 3 | Aren't you tired of generating so many keys, and having to add them manually to each box and version control forge you want to access? 4 | 5 | Lockbox is a centralised store for your personal SSH keys. It supports: 6 | 7 | - Any `sshd` through an `AuthorizedKeysCommand` configuration directive 8 | - [GitHub.com](https://github.com/) via OAuth 9 | 10 | Written using [Starlette](https://www.starlette.io/). 11 | 12 | ![An example instance of Lockbox, logged in as half-kh-hacker](docs/scrot.png) 13 | 14 | ## Security 15 | 16 | **Beware:** For all the systems you hook it up to, Lockbox is a [single point of failure](https://en.wikipedia.org/wiki/Single_point_of_failure). 17 | That is, if an adversary can gain control of your account on the Lockbox instance you are using, 18 | they can deploy their own key and access any of the linked systems. 19 | 20 | Furthermore, the administrator of the Lockbox instance you are using is capable of adding keys under any user, 21 | so make sure you trust the admin. (In the best-case scenario, the admin is you. The multi-user functionality 22 | of the software is designed for teams.) 23 | 24 | ## Details 25 | 26 | Without authentication, keys are publicised without comment fields, à la GitHub's `https://github.com/.keys` route. 27 | 28 | With authentication, it is possible to access the keys with the comment field intact. 29 | 30 | ## Connecting using `AuthorizedKeysCommand` 31 | 32 | 1. Copy `ssh-lockbox/contrib/check_keys.sh` to `/etc/ssh/lockbox_check_keys.sh`. 33 | 2. Set up your `sshd_config`: 34 | 35 | ``` 36 | AuthorizedKeysCommand /etc/ssh/lockbox_check_keys.sh "%u" 37 | AuthorizedKeysCommandUser root 38 | ``` 39 | 40 | Each user account you want to be able to be accessible through Lockbox must have a `~/.config/lockbox.conf`, containing two lines: 41 | 42 | ``` 43 | https://my-lockbox.example.com 44 | my-lockbox-username 45 | ``` 46 | 47 | 48 | 49 | When the key check is complete, the results will cache as a section appended at the end of `.ssh/authorized_keys`: 50 | 51 | ``` 52 | ssh-ed25519 ... me@some-machine.local 53 | 54 | ### LOCKBOX SECTION ### 55 | # Please do not edit under this sectoin. It is automatically generated, 56 | # and may be wiped at any time. 57 | ssh-ed25519 ... 58 | ssh-ed25519 ... 59 | ssh-ed25519 ... 60 | ``` 61 | 62 | ## Connecting a GitHub account 63 | 64 | Currently, only GitHub.com is supported. Enterprise instances of GitHub on another domain are not. 65 | 66 | First, the administrator of the Lockbox instance must create an OAuth application with GitHub: 67 | 68 | You can do this heading to [github.com/settings/developers](https://github.com/settings/developers) and adding a new OAuth app. 69 | I recommend you name it "Lockbox @ <domain>". 70 | 71 | The callback URL for the OAuth app should be `https:///integrations/github/complete`. 72 | 73 | Then, put the client ID and client secret in the corresponding fields in the application's configuration. 74 | 75 | Any user can then navigate to `https:///integrations/github/initiate` to set up a GitHub integration, after which all of their keys added to Lockbox will be pushed to GitHub, and all future key deployments will also trigger the key being sent to GitHub. 76 | 77 | ## Running the Server 78 | 79 | ``` 80 | $ # set up a virtualenv, or don't, your choice. then: 81 | $ pip install -r requirements.txt 82 | $ cp .env.schema .env; $EDITOR .env # Set up the DATABASE_URL value 83 | $ alembic upgrade head # Run migrations to initialise the database 84 | $ ./run_prod.sh ./lockbox.sock # Starts a gunicorn instance (with a uvicorn worker) listening at unix:./lockbox.sock 85 | $ # Use nginx to proxy into the socket 86 | ``` 87 | 88 | ### Configuration 89 | 90 | Configuration can be achieved via a `.env` file or through environment variables. 91 | 92 | The configuration entries are as follows: 93 | 94 | - `DATABASE_URL`: A connection URL for the application's database. A configuration (using SQLite) for development is included in `.env.schema`. 95 | - `SESSION_SECRET_KEY`: The secret key to sign session information with. This should be a randomly generated blob of data. 96 | - `REGISTRATION_ENABLED`: Whether to permit arbitrary user registration. 97 | - `GITHUB_CLIENT_ID`: An OAuth client ID for GitHub integration. 98 | - `GITHUB_CLIENT_SECRET`: An OAuth client secret for GitHub integration. 99 | 100 | **Note:** If you want to use a PostgreSQL database, please use `aiopg` instead of `postgres`. `postgres` with encode.io's `databases` module returns non-standard row results, and will cause all sorts of weird errors. 101 | -------------------------------------------------------------------------------- /lockbox/integrations/github.py: -------------------------------------------------------------------------------- 1 | from typing import List, Set 2 | 3 | from urllib.parse import urlencode 4 | import httpx 5 | 6 | import json 7 | import asyncio 8 | 9 | from starlette.requests import Request 10 | from starlette.responses import RedirectResponse, PlainTextResponse 11 | from starlette.background import BackgroundTask 12 | 13 | from lockbox.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, OAUTH_BASE_URL 14 | from lockbox.db import database, users, user_integrations, keys 15 | from lockbox.flashes import flash 16 | 17 | from lockbox.integrations import ThirdPartyIntegration, get_integration_from_db 18 | 19 | GITHUB_INTEGRATION_TYPE = "GitHub" 20 | 21 | GITHUB_REDIRECT_URL = OAUTH_BASE_URL + "/integrations/github/complete" 22 | 23 | 24 | def github_integration_enabled(): 25 | return bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET) 26 | 27 | 28 | async def initiate_github_integration(request: Request): 29 | if not github_integration_enabled(): 30 | return PlainTextResponse("GitHub integration is currently disabled.") 31 | 32 | if not request.user.is_authenticated: 33 | flash(request, "error", "You are not logged in!") 34 | return RedirectResponse("/", 303) 35 | 36 | params = { 37 | "client_id": GITHUB_CLIENT_ID, 38 | "redirect_uri": GITHUB_REDIRECT_URL, 39 | "scope": "admin:public_key", 40 | } 41 | 42 | return RedirectResponse( 43 | "https://github.com/login/oauth/authorize?" + urlencode(params) 44 | ) 45 | 46 | 47 | async def complete_github_integration(request: Request): 48 | if not github_integration_enabled(): 49 | return PlainTextResponse("GitHub integration is currently disabled.") 50 | 51 | if not request.user.is_authenticated: 52 | flash(request, "error", "You are not logged in!") 53 | return RedirectResponse("/") 54 | 55 | code = request.query_params.get("code") 56 | if not code: 57 | flash(request, "error", "Invalid OAuth code") 58 | return RedirectResponse("/") 59 | 60 | query = users.select().where(users.c.username == request.user.username) 61 | user_id = await database.fetch_val(query) 62 | 63 | async with httpx.AsyncClient() as http: 64 | res = await http.post( 65 | "https://github.com/login/oauth/access_token", 66 | params={ 67 | "client_id": GITHUB_CLIENT_ID, 68 | "client_secret": str(GITHUB_CLIENT_SECRET), 69 | "code": code, 70 | }, 71 | headers={"Accept": "application/json"}, 72 | ) 73 | 74 | if res.is_error or "error" in res.json(): # Why is this a 2xx, GitHub? 75 | print("Could not complete GitHub OAuth:") 76 | print(res.json()) 77 | flash( 78 | request, 79 | "error", 80 | "An error occurred while trying to authenticate with GitHub", 81 | ) 82 | return RedirectResponse("/") 83 | 84 | async with database.transaction(): 85 | query = user_integrations.insert().values( 86 | user_id=user_id, 87 | integration_type=GITHUB_INTEGRATION_TYPE, 88 | integration_domain="github.com", 89 | integration_data=res.json(), # { "access_token": ..., "token_type": ... } 90 | ) 91 | await database.execute(query) 92 | 93 | query = ( # Why does encode.io's databases module make it so hard to get back the created record? 94 | user_integrations.select() 95 | .where(user_integrations.c.user_id == user_id) 96 | .where(user_integrations.c.integration_type == GITHUB_INTEGRATION_TYPE) 97 | .where(user_integrations.c.integration_domain == "github.com") 98 | ) 99 | user_integration_row = await database.fetch_one(query) 100 | 101 | flash(request, "success", "Added GitHub integration for 'github.com'") 102 | 103 | async with get_integration_from_db(user_integration_row) as integration: 104 | return RedirectResponse( 105 | "/", background=BackgroundTask(integration.full_sync) 106 | ) 107 | 108 | 109 | async def force_sync_github_integration(request): 110 | """Force sync any GitHub integrations for this user (via POST request only)""" 111 | 112 | if not request.user.is_authenticated: 113 | flash(request, "error", "You are not logged in!") 114 | return RedirectResponse("/", 303) 115 | 116 | username = request.user.username 117 | query = users.select().where(users.c.username == request.user.username) 118 | user_id = await database.fetch_val(query) 119 | 120 | query = ( 121 | user_integrations.select() 122 | .where(user_integrations.c.user_id == user_id) 123 | .where(user_integrations.c.integration_type == GITHUB_INTEGRATION_TYPE) 124 | ) 125 | 126 | integrations = await database.fetch_all(query) 127 | for integration_row in integrations: 128 | async with get_integration_from_db(integration_row) as integration: 129 | await integration.full_sync() 130 | 131 | return PlainTextResponse("Success.") 132 | 133 | 134 | def _create_authed_http(access_token) -> httpx.AsyncClient: 135 | return httpx.AsyncClient( 136 | headers={ 137 | "Authorization": f"token {access_token}", 138 | "Accept": "application/vnd.github.v3+json", 139 | } 140 | ) 141 | 142 | 143 | class GitHubIntegration(ThirdPartyIntegration): 144 | def __init__(self, user_id, integration_domain, integration_data): 145 | super().__init__(user_id, integration_domain, integration_data) 146 | self.http = _create_authed_http(integration_data["access_token"]) 147 | 148 | async def aclose(self): 149 | await self.http.aclose() 150 | 151 | async def _iter_keys(self): 152 | domain = self.integration_domain 153 | res = await self.http.get( 154 | f"https://api.{domain}/user/keys", params={"per_page": 100} 155 | ) 156 | 157 | for existing_key in res.json(): 158 | yield existing_key 159 | 160 | while next_url := res.links.get("next"): 161 | res = await self.http.get(next_url) 162 | for existing_key in res.json(): 163 | yield existing_key 164 | 165 | async def full_sync(self): 166 | ssh_keys = await database.fetch_all( 167 | keys.select().where(keys.c.user_id == self.user_id) 168 | ) 169 | 170 | existing_keys = set() 171 | async for key in self._iter_keys(): 172 | existing_keys.add(key["key"]) 173 | 174 | coroutines = [] 175 | for ssh_key in ssh_keys: 176 | key_algorithm, key_contents, key_comment = ssh_key[2:] 177 | if f"{key_algorithm} {key_contents}" in existing_keys: 178 | continue 179 | 180 | coroutines.append( 181 | self._deploy_key(key_algorithm, key_contents, key_comment) 182 | ) 183 | 184 | await asyncio.gather(*coroutines) 185 | 186 | async def on_new_key(self, key_algorithm, key_contents, key_comment): 187 | domain = self.integration_domain 188 | response = await self.http.post( 189 | f"https://api.{domain}/user/keys", 190 | json={ 191 | "title": key_comment, 192 | "key": f"{key_algorithm} {key_contents} {key_comment}", 193 | }, 194 | ) 195 | 196 | if response.is_error: 197 | print("Error while deploying key to GitHub:") 198 | print(response.json()) 199 | 200 | async def on_delete_key(self, key_algorithm, key_contents, key_comment): 201 | domain = self.integration_domain 202 | 203 | async for key in self._iter_keys(): 204 | if key["key"] == f"{key_algorithm} {key_contents}": 205 | key_id = key["id"] 206 | 207 | response = await self.http.delete( 208 | f"https://api.{domain}/user/keys/{key_id}" 209 | ) 210 | 211 | if response.is_error: 212 | print("Error while deleting key from GitHub:") 213 | print(response.json()) 214 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "59195abf8d4fee1da54a9de1959dd3d2ebffd9a9f72cf4f6cdbc45658bf6a11d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiofiles": { 20 | "hashes": [ 21 | "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb", 22 | "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.5.0" 26 | }, 27 | "aiosqlite": { 28 | "hashes": [ 29 | "sha256:19b984b6702aed9f1c85c023f37296954547fc4030dae8e9d027b2a930bed78b", 30 | "sha256:a2884793f4dc8f2798d90e1dfecb2b56a6d479cf039f7ec52356a7fd5f3bdc57" 31 | ], 32 | "version": "==0.15.0" 33 | }, 34 | "alembic": { 35 | "hashes": [ 36 | "sha256:035ab00497217628bf5d0be82d664d8713ab13d37b630084da8e1f98facf4dbf" 37 | ], 38 | "index": "pypi", 39 | "version": "==1.4.2" 40 | }, 41 | "bcrypt": { 42 | "hashes": [ 43 | "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", 44 | "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42", 45 | "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294", 46 | "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161", 47 | "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752", 48 | "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31", 49 | "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5", 50 | "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c", 51 | "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0", 52 | "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de", 53 | "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e", 54 | "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052", 55 | "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09", 56 | "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105", 57 | "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133", 58 | "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1", 59 | "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", 60 | "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" 61 | ], 62 | "index": "pypi", 63 | "version": "==3.1.7" 64 | }, 65 | "certifi": { 66 | "hashes": [ 67 | "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", 68 | "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" 69 | ], 70 | "version": "==2020.6.20" 71 | }, 72 | "cffi": { 73 | "hashes": [ 74 | "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", 75 | "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", 76 | "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", 77 | "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", 78 | "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", 79 | "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", 80 | "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", 81 | "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", 82 | "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", 83 | "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", 84 | "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", 85 | "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", 86 | "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", 87 | "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", 88 | "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", 89 | "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", 90 | "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", 91 | "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", 92 | "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", 93 | "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", 94 | "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", 95 | "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", 96 | "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", 97 | "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", 98 | "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", 99 | "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", 100 | "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", 101 | "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" 102 | ], 103 | "version": "==1.14.1" 104 | }, 105 | "chardet": { 106 | "hashes": [ 107 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 108 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 109 | ], 110 | "version": "==3.0.4" 111 | }, 112 | "click": { 113 | "hashes": [ 114 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 115 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 116 | ], 117 | "version": "==7.1.2" 118 | }, 119 | "databases": { 120 | "extras": [ 121 | "sqlite" 122 | ], 123 | "hashes": [ 124 | "sha256:78b758884ca585b81272af1de697e0c8a3034de92bdd08e9ac47436ef0cdca56", 125 | "sha256:ee8dcece15a86359ef06414a6afcc15da15f5d078dc09af2e3a5f9dbfee4dce9" 126 | ], 127 | "index": "pypi", 128 | "version": "==0.3.2" 129 | }, 130 | "h11": { 131 | "hashes": [ 132 | "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", 133 | "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" 134 | ], 135 | "version": "==0.9.0" 136 | }, 137 | "h2": { 138 | "hashes": [ 139 | "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5", 140 | "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14" 141 | ], 142 | "version": "==3.2.0" 143 | }, 144 | "hpack": { 145 | "hashes": [ 146 | "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", 147 | "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2" 148 | ], 149 | "version": "==3.0.0" 150 | }, 151 | "hstspreload": { 152 | "hashes": [ 153 | "sha256:34a8cd70e73984248139dc495608d858c9a2ac30fdc90fe7f8ccf5bb05a4aaf3", 154 | "sha256:b635a8f13dc9207f8c2596a788d20ef7dacc73b1f740c975ff7824c22db78312" 155 | ], 156 | "version": "==2020.7.29" 157 | }, 158 | "httpcore": { 159 | "hashes": [ 160 | "sha256:9850fe97a166a794d7e920590d5ec49a05488884c9fc8b5dba8561effab0c2a0", 161 | "sha256:ecc5949310d9dae4de64648a4ce529f86df1f232ce23dcfefe737c24d21dfbe9" 162 | ], 163 | "version": "==0.9.1" 164 | }, 165 | "httptools": { 166 | "hashes": [ 167 | "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be", 168 | "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d", 169 | "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce", 170 | "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2", 171 | "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6", 172 | "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f", 173 | "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009", 174 | "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce", 175 | "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a", 176 | "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c", 177 | "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4", 178 | "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437" 179 | ], 180 | "markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'", 181 | "version": "==0.1.1" 182 | }, 183 | "httpx": { 184 | "hashes": [ 185 | "sha256:32d930858eab677bc29a742aaa4f096de259f1c78c68a90ad11f5c3c04f08335", 186 | "sha256:3642bd13e90b80ba8a243a730275eb10a4c26ec96f5fc16b87e458d4ab21efae" 187 | ], 188 | "index": "pypi", 189 | "version": "==0.13.3" 190 | }, 191 | "hyperframe": { 192 | "hashes": [ 193 | "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40", 194 | "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f" 195 | ], 196 | "version": "==5.2.0" 197 | }, 198 | "idna": { 199 | "hashes": [ 200 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 201 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 202 | ], 203 | "version": "==2.10" 204 | }, 205 | "itsdangerous": { 206 | "hashes": [ 207 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 208 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 209 | ], 210 | "index": "pypi", 211 | "version": "==1.1.0" 212 | }, 213 | "jinja2": { 214 | "hashes": [ 215 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 216 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 217 | ], 218 | "index": "pypi", 219 | "version": "==2.11.2" 220 | }, 221 | "mako": { 222 | "hashes": [ 223 | "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", 224 | "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" 225 | ], 226 | "version": "==1.1.3" 227 | }, 228 | "markupsafe": { 229 | "hashes": [ 230 | "sha256:06358015a4dee8ee23ae426bf885616ab3963622defd829eb45b44e3dee3515f", 231 | "sha256:0b0c4fc852c5f02c6277ef3b33d23fcbe89b1b227460423e3335374da046b6db", 232 | "sha256:267677fc42afed5094fc5ea1c4236bbe4b6a00fe4b08e93451e65ae9048139c7", 233 | "sha256:303cb70893e2c345588fb5d5b86e0ca369f9bb56942f03064c5e3e75fa7a238a", 234 | "sha256:3c9b624a0d9ed5a5093ac4edc4e823e6b125441e60ef35d36e6f4a6fdacd5054", 235 | "sha256:42033e14cae1f6c86fc0c3e90d04d08ce73ac8e46ba420a0d22d545c2abd4977", 236 | "sha256:4e4a99b6af7bdc0856b50020c095848ec050356a001e1f751510aef6ab14d0e0", 237 | "sha256:4eb07faad54bb07427d848f31030a65a49ebb0cec0b30674f91cf1ddd456bfe4", 238 | "sha256:63a7161cd8c2bc563feeda45df62f42c860dd0675e2b8da2667f25bb3c95eaba", 239 | "sha256:68e0fd039b68d2945b4beb947d4023ca7f8e95b708031c345762efba214ea761", 240 | "sha256:8092a63397025c2f655acd42784b2a1528339b90b987beb9253f22e8cdbb36c3", 241 | "sha256:841218860683c0f2223e24756843d84cc49cccdae6765e04962607754a52d3e0", 242 | "sha256:94076b2314bd2f6cfae508ad65b4d493e3a58a50112b7a2cbb6287bdbc404ae8", 243 | "sha256:9d22aff1c5322e402adfb3ce40839a5056c353e711c033798cf4f02eb9f5124d", 244 | "sha256:b0e4584f62b3e5f5c1a7bcefd2b52f236505e6ef032cc508caa4f4c8dc8d3af1", 245 | "sha256:b1163ffc1384d242964426a8164da12dbcdbc0de18ea36e2c34b898ed38c3b45", 246 | "sha256:beac28ed60c8e838301226a7a85841d0af2068eba2dcb1a58c2d32d6c05e440e", 247 | "sha256:c29f096ce79c03054a1101d6e5fe6bf04b0bb489165d5e0e9653fb4fe8048ee1", 248 | "sha256:c58779966d53e5f14ba393d64e2402a7926601d1ac8adeb4e83893def79d0428", 249 | "sha256:cfe14b37908eaf7d5506302987228bff69e1b8e7071ccd4e70fd0283b1b47f0b", 250 | "sha256:e834249c45aa9837d0753351cdca61a4b8b383cc9ad0ff2325c97ff7b69e72a6", 251 | "sha256:eed1b234c4499811ee85bcefa22ef5e466e75d132502226ed29740d593316c1f" 252 | ], 253 | "version": "==2.0.0a1" 254 | }, 255 | "pycparser": { 256 | "hashes": [ 257 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 258 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 259 | ], 260 | "version": "==2.20" 261 | }, 262 | "python-dateutil": { 263 | "hashes": [ 264 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 265 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 266 | ], 267 | "version": "==2.8.1" 268 | }, 269 | "python-editor": { 270 | "hashes": [ 271 | "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", 272 | "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", 273 | "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8" 274 | ], 275 | "version": "==1.0.4" 276 | }, 277 | "python-multipart": { 278 | "hashes": [ 279 | "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43" 280 | ], 281 | "index": "pypi", 282 | "version": "==0.0.5" 283 | }, 284 | "rfc3986": { 285 | "hashes": [ 286 | "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", 287 | "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" 288 | ], 289 | "version": "==1.4.0" 290 | }, 291 | "six": { 292 | "hashes": [ 293 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 294 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 295 | ], 296 | "version": "==1.15.0" 297 | }, 298 | "sniffio": { 299 | "hashes": [ 300 | "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", 301 | "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" 302 | ], 303 | "version": "==1.1.0" 304 | }, 305 | "sqlalchemy": { 306 | "hashes": [ 307 | "sha256:0942a3a0df3f6131580eddd26d99071b48cfe5aaf3eab2783076fbc5a1c1882e", 308 | "sha256:0ec575db1b54909750332c2e335c2bb11257883914a03bc5a3306a4488ecc772", 309 | "sha256:109581ccc8915001e8037b73c29590e78ce74be49ca0a3630a23831f9e3ed6c7", 310 | "sha256:16593fd748944726540cd20f7e83afec816c2ac96b082e26ae226e8f7e9688cf", 311 | "sha256:427273b08efc16a85aa2b39892817e78e3ed074fcb89b2a51c4979bae7e7ba98", 312 | "sha256:50c4ee32f0e1581828843267d8de35c3298e86ceecd5e9017dc45788be70a864", 313 | "sha256:512a85c3c8c3995cc91af3e90f38f460da5d3cade8dc3a229c8e0879037547c9", 314 | "sha256:57aa843b783179ab72e863512e14bdcba186641daf69e4e3a5761d705dcc35b1", 315 | "sha256:621f58cd921cd71ba6215c42954ffaa8a918eecd8c535d97befa1a8acad986dd", 316 | "sha256:6ac2558631a81b85e7fb7a44e5035347938b0a73f5fdc27a8566777d0792a6a4", 317 | "sha256:716754d0b5490bdcf68e1e4925edc02ac07209883314ad01a137642ddb2056f1", 318 | "sha256:736d41cfebedecc6f159fc4ac0769dc89528a989471dc1d378ba07d29a60ba1c", 319 | "sha256:8619b86cb68b185a778635be5b3e6018623c0761dde4df2f112896424aa27bd8", 320 | "sha256:87fad64529cde4f1914a5b9c383628e1a8f9e3930304c09cf22c2ae118a1280e", 321 | "sha256:89494df7f93b1836cae210c42864b292f9b31eeabca4810193761990dc689cce", 322 | "sha256:8cac7bb373a5f1423e28de3fd5fc8063b9c8ffe8957dc1b1a59cb90453db6da1", 323 | "sha256:8fd452dc3d49b3cc54483e033de6c006c304432e6f84b74d7b2c68afa2569ae5", 324 | "sha256:adad60eea2c4c2a1875eb6305a0b6e61a83163f8e233586a4d6a55221ef984fe", 325 | "sha256:c26f95e7609b821b5f08a72dab929baa0d685406b953efd7c89423a511d5c413", 326 | "sha256:cbe1324ef52ff26ccde2cb84b8593c8bf930069dfc06c1e616f1bfd4e47f48a3", 327 | "sha256:d05c4adae06bd0c7f696ae3ec8d993ed8ffcc4e11a76b1b35a5af8a099bd2284", 328 | "sha256:d98bc827a1293ae767c8f2f18be3bb5151fd37ddcd7da2a5f9581baeeb7a3fa1", 329 | "sha256:da2fb75f64792c1fc64c82313a00c728a7c301efe6a60b7a9fe35b16b4368ce7", 330 | "sha256:e4624d7edb2576cd72bb83636cd71c8ce544d8e272f308bd80885056972ca299", 331 | "sha256:e89e0d9e106f8a9180a4ca92a6adde60c58b1b0299e1b43bd5e0312f535fbf33", 332 | "sha256:f11c2437fb5f812d020932119ba02d9e2bc29a6eca01a055233a8b449e3e1e7d", 333 | "sha256:f57be5673e12763dd400fea568608700a63ce1c6bd5bdbc3cc3a2c5fdb045274", 334 | "sha256:fc728ece3d5c772c196fd338a99798e7efac7a04f9cb6416299a3638ee9a94cd" 335 | ], 336 | "index": "pypi", 337 | "version": "==1.3.18" 338 | }, 339 | "starlette": { 340 | "hashes": [ 341 | "sha256:bd2ffe5e37fb75d014728511f8e68ebf2c80b0fa3d04ca1479f4dc752ae31ac9", 342 | "sha256:ebe8ee08d9be96a3c9f31b2cb2a24dbdf845247b745664bd8a3f9bd0c977fdbc" 343 | ], 344 | "index": "pypi", 345 | "version": "==0.13.6" 346 | }, 347 | "typing-extensions": { 348 | "hashes": [ 349 | "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", 350 | "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", 351 | "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" 352 | ], 353 | "version": "==3.7.4.2" 354 | }, 355 | "uvicorn": { 356 | "hashes": [ 357 | "sha256:1d46a22cc55a52f5567e0c66f000ae56f26263e44cef59b7c885bf10f487ce6e", 358 | "sha256:b50f7f4c0c499c9b8d0280924cfbd24b90ba02456e3dc80934b9a786a291f09f" 359 | ], 360 | "index": "pypi", 361 | "version": "==0.11.7" 362 | }, 363 | "uvloop": { 364 | "hashes": [ 365 | "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", 366 | "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", 367 | "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", 368 | "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", 369 | "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", 370 | "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", 371 | "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", 372 | "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", 373 | "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362" 374 | ], 375 | "markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'", 376 | "version": "==0.14.0" 377 | }, 378 | "websockets": { 379 | "hashes": [ 380 | "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", 381 | "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", 382 | "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", 383 | "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", 384 | "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", 385 | "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", 386 | "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", 387 | "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", 388 | "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", 389 | "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", 390 | "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", 391 | "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", 392 | "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", 393 | "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", 394 | "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", 395 | "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", 396 | "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", 397 | "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", 398 | "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", 399 | "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", 400 | "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", 401 | "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" 402 | ], 403 | "version": "==8.1" 404 | } 405 | }, 406 | "develop": { 407 | "appdirs": { 408 | "hashes": [ 409 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 410 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 411 | ], 412 | "version": "==1.4.4" 413 | }, 414 | "attrs": { 415 | "hashes": [ 416 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 417 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 418 | ], 419 | "version": "==19.3.0" 420 | }, 421 | "black": { 422 | "hashes": [ 423 | "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", 424 | "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" 425 | ], 426 | "index": "pypi", 427 | "version": "==19.10b0" 428 | }, 429 | "click": { 430 | "hashes": [ 431 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 432 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 433 | ], 434 | "version": "==7.1.2" 435 | }, 436 | "pathspec": { 437 | "hashes": [ 438 | "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", 439 | "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" 440 | ], 441 | "version": "==0.8.0" 442 | }, 443 | "regex": { 444 | "hashes": [ 445 | "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", 446 | "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", 447 | "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", 448 | "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", 449 | "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", 450 | "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", 451 | "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", 452 | "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", 453 | "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", 454 | "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", 455 | "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", 456 | "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", 457 | "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", 458 | "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", 459 | "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", 460 | "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", 461 | "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", 462 | "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", 463 | "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", 464 | "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", 465 | "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" 466 | ], 467 | "version": "==2020.7.14" 468 | }, 469 | "toml": { 470 | "hashes": [ 471 | "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", 472 | "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" 473 | ], 474 | "version": "==0.10.1" 475 | }, 476 | "typed-ast": { 477 | "hashes": [ 478 | "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", 479 | "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", 480 | "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", 481 | "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", 482 | "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", 483 | "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", 484 | "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", 485 | "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", 486 | "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", 487 | "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", 488 | "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", 489 | "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", 490 | "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", 491 | "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", 492 | "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", 493 | "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", 494 | "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", 495 | "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", 496 | "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", 497 | "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", 498 | "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" 499 | ], 500 | "version": "==1.4.1" 501 | } 502 | } 503 | } 504 | --------------------------------------------------------------------------------