├── .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 |
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 |
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 |
19 | {% for category, message in flashes %}
20 | - {{ message }}
21 | {% endfor %}
22 |
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 |
12 |
15 |
16 |
22 |
23 | {% if user_ssh_keys %}
24 |
46 | {% endif %}
47 | {% else %}
48 |
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 | 
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 |
--------------------------------------------------------------------------------