Skip to content

Commit

Permalink
Add encryption to db backups/exports.
Browse files Browse the repository at this point in the history
  • Loading branch information
liffiton committed Dec 7, 2024
1 parent e746fd1 commit 870ce65
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 4 deletions.
21 changes: 21 additions & 0 deletions src/gened/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ def before_request() -> None:
""" Apply decorator to protect all admin blueprint endpoints. """


@dataclass(frozen=True)
class DBDownloadStatus:
"""Status of database download encryption."""
encrypted: bool
reason: str | None = None # reason provided if not encrypted

@bp.context_processor
def inject_db_download_status() -> dict[str, DBDownloadStatus]:
if platform.system() == "Windows":
status = DBDownloadStatus(False, "Encryption unavailable on Windows servers.")
elif not current_app.config.get('AGE_PUBLIC_KEY'):
status = DBDownloadStatus(False, "No encryption key configured, AGE_PUBLIC_KEY not set.")
else:
status = DBDownloadStatus(True)
return {'db_download_status': status}


@dataclass(frozen=True)
class AdminLink:
"""Represents a link in the admin interface.
Expand Down Expand Up @@ -351,12 +368,16 @@ def get_db_file() -> Response:
db_name = current_app.config['DATABASE_NAME']
db_basename = Path(db_name).stem
dl_name = f"{db_basename}_{date.today().strftime('%Y%m%d')}.db"
if current_app.config.get('AGE_PUBLIC_KEY'):
dl_name += '.age'

if platform.system() == "Windows":
# Slightly unsafe way to do it, because the file may be written while
# send_file is sending it. Temp file issues make it hard to do
# otherwise on Windows, though, and no one should run a production
# server for this on Windows, anyway.
if current_app.config.get('AGE_PUBLIC_KEY'):
current_app.logger.warning("Database download on Windows does not support encryption")
return send_file(current_app.config['DATABASE'],
mimetype='application/vnd.sqlite3',
as_attachment=True, download_name=dl_name)
Expand Down
15 changes: 15 additions & 0 deletions src/gened/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

import flask.app
import pyrage
from dotenv import load_dotenv
from flask import Flask, render_template, send_from_directory
from flask.wrappers import Response
Expand Down Expand Up @@ -145,6 +146,20 @@ def create_app_base(import_name: str, app_config: dict[str, Any], instance_path:
app.logger.error(f"{varname} environment variable not set.")
sys.exit(1)

# Optional variables:
# - AGE_PUBLIC_KEY: used to encrypt database backups and exports
try:
varname = "AGE_PUBLIC_KEY"
env_var = os.environ[varname]
# test the key
pyrage.ssh.Recipient.from_str(env_var)
base_config[varname] = env_var
except pyrage.RecipientError:
app.logger.error("Invalid key provided in AGE_PUBLIC_KEY. Must be an SSH public key.")
sys.exit(1)
except KeyError:
app.logger.warning(f"{varname} environment variable not set.")

# CLIENT_ID/CLIENT_SECRET vars are used by authlib:
# https://docs.authlib.org/en/latest/client/flask.html#configuration
# But the application will run without them; it just won't provide login
Expand Down
29 changes: 27 additions & 2 deletions src/gened/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pathlib import Path

import click
import pyrage
from flask import current_app, g
from flask.app import Flask
from werkzeug.security import generate_password_hash
Expand Down Expand Up @@ -57,19 +58,43 @@ def get_db() -> sqlite3.Connection:
return g.db


def encrypt_file(source: Path, target: Path) -> None:
"""Encrypt a file using the configured public key"""
pubkey = current_app.config['AGE_PUBLIC_KEY']
recipient = pyrage.ssh.Recipient.from_str(pubkey)
pyrage.encrypt_file(str(source), str(target), [recipient])


def backup_db(target: Path) -> None:
""" Safely make a backup of the database to the given path.
target: Path object to the location of the new backup. Must not exist yet or be empty.
If AGE_PUBLIC_KEY is set, the backup will be encrypted using that key.
target: Path object to the location of the new backup. Must not exist yet or be empty.
"""
if target.exists() and target.stat().st_size > 0:
raise FileExistsError(errno.EEXIST, "File already exists and is not empty", target)

encryption_key = current_app.config.get('AGE_PUBLIC_KEY')
if not encryption_key:
current_app.logger.warning("Creating database backup *without* encryption - no AGE_PUBLIC_KEY configured.")

db = get_db()
tmp_db = sqlite3.connect(target)

# Create unencrypted backup (either final or temporary)
db_output = target
if encryption_key:
db_output = db_output.with_suffix('.tmp')
tmp_db = sqlite3.connect(db_output)
with tmp_db:
db.backup(tmp_db)
tmp_db.close()

if encryption_key:
# Encrypt the backup
try:
encrypt_file(db_output, target)
finally:
db_output.unlink() # Clean up temp file


def close_db(e: BaseException | None = None) -> None: # noqa: ARG001 - unused function argument
db = g.pop('db', None)
Expand Down
2 changes: 2 additions & 0 deletions src/gened/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ def _apply_migrations(migrations: Iterable[MigrationDict]) -> None:
backup_dir = Path(current_app.instance_path) / "backups"
backup_dir.mkdir(mode=0o770, exist_ok=True)
backup_dest = backup_dir / f"{current_app.config['DATABASE_NAME']}.{timestamp}.bak"
if current_app.config.get('AGE_PUBLIC_KEY'):
backup_dest = backup_dest.with_suffix('.age')

backup_db(backup_dest)

Expand Down
18 changes: 17 additions & 1 deletion src/gened/templates/admin_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,23 @@
</div>
<div class="navbar-end">
{% for link in admin_links_right %}
<a class="navbar-item is-tab {{ 'is-active' if request.endpoint == link.endpoint else ''}}" href="{{url_for(link.endpoint)}}">{{link.display}}</a>
<a class="navbar-item is-tab {{ 'is-active' if request.endpoint == link.endpoint else ''}}" href="{{url_for(link.endpoint)}}">
{{link.display}}
{% if link.endpoint == 'admin.get_db_file' %}
<span class="tag ml-2 {% if db_download_status.encrypted %}is-success{% else %}is-warning{% endif %}"
title="{% if db_download_status.encrypted %}Download will be encrypted{% else %}{{db_download_status.reason}}{% endif %}"
>
<span class="icon">
<svg aria-hidden="true" style="height: 80%;">
<use href="{% if db_download_status.encrypted %}#svg_admin_check{% else %}#svg_admin_alert{% endif %}" />
</svg>
</span>
{% if not db_download_status.encrypted %}
<span class="text" style="margin-left: -0.5em;">&nbsp;&nbsp;unencrypted</span>
{% endif %}
</span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion src/gened/templates/icons.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="3" y1="15" x2="21" y2="15"></line><line x1="12" y1="3" x2="12" y2="21"></line>
</symbol>
<symbol id="svg_admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m4.243 5.21 14.39 12.472"/>
</symbol>
<symbol id="svg_admin_alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><path d="M12 8v4"></path><path d="M12 16h.01"></path>
</symbol>
<symbol id="svg_admin_check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>
</symbol>
<symbol id="svg_logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
</symbol>
Expand All @@ -42,5 +48,5 @@
<symbol id="svg_trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></symbol>
<symbol id="svg_chevron_down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="svg_pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
<symbol id="svg_link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
<symbol id="svg_link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
</svg>
70 changes: 70 additions & 0 deletions tests/test_backup_encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: 2024 Mark Liffiton <[email protected]>
#
# SPDX-License-Identifier: AGPL-3.0-only

import platform
import subprocess
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory


def test_db_download_status(app, monkeypatch):
"""Test that db_download_status correctly reflects encryption availability"""
with app.app_context():
from gened.admin import inject_db_download_status

# No key configured
app.config['AGE_PUBLIC_KEY'] = None
status = inject_db_download_status()['db_download_status']
assert not status.encrypted
assert status.reason is not None
assert "No encryption key configured" in status.reason

# Mock Windows platform
monkeypatch.setattr(platform, "system", lambda: "Windows")
app.config['AGE_PUBLIC_KEY'] = "ssh-ed25519 AAAAC3..."
status = inject_db_download_status()['db_download_status']
assert not status.encrypted
assert status.reason is not None
assert "Windows" in status.reason

# Mock non-Windows platform
monkeypatch.setattr(platform, "system", lambda: "Linux")
status = inject_db_download_status()['db_download_status']
assert status.encrypted
assert status.reason is None


def test_backup_db_encryption(app):
"""Test that backup_db handles encryption configuration correctly"""
with app.app_context():
from gened.db import backup_db

# Test unencrypted backup (no key configured)
app.config['AGE_PUBLIC_KEY'] = None
with NamedTemporaryFile() as backup_file:
backup_db(Path(backup_file.name))
header = backup_file.read(16)
assert header.startswith(b'SQLite format 3') # unencrypted SQLite file

# Test encrypted backup with a real SSH key (test does not run on Windows, where gened does not support this)
if platform.system() == "Windows":
return

with TemporaryDirectory() as temp_dir:
# Generate SSH keypair
key_path = Path(temp_dir) / "temp_key"
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)],
check=True, capture_output=True
)
pubkey = (key_path.with_suffix(".pub")).read_text().strip()

# Configure and test encryption
app.config['AGE_PUBLIC_KEY'] = pubkey

backup_path = Path(temp_dir) / "backup.db"
backup_db(backup_path)
with backup_path.open('rb') as f:
header = f.read(6)
assert header == b'age-en' # age encryption header

0 comments on commit 870ce65

Please sign in to comment.