diff --git a/karldns/security.py b/karldns/security.py index 06654e7..bccc14d 100644 --- a/karldns/security.py +++ b/karldns/security.py @@ -15,6 +15,7 @@ from .settings import DdnsSettings DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock" DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60 DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60 +BEARER_SECRET_PREFIX = "karl_" PRIVATE_PROXY_NETWORKS = tuple( ip_network(network) for network in ( @@ -62,6 +63,10 @@ def hash_lookup_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def new_bearer_secret() -> str: + return f"{BEARER_SECRET_PREFIX}{secrets.token_urlsafe(32)}" + + def sign_dashboard_unlock_cookie( hostname: str, management_slug: str, diff --git a/karldns/store/accounts.py b/karldns/store/accounts.py index 5b3e83b..d2127be 100644 --- a/karldns/store/accounts.py +++ b/karldns/store/accounts.py @@ -1,17 +1,16 @@ from __future__ import annotations -import secrets from datetime import UTC, datetime -from ..security import hash_lookup_token, hash_secret, verify_secret +from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret from .types import AccountRow class AccountsMixin: def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow: now = datetime.now(UTC).isoformat() - update_slug = secrets.token_urlsafe(32) - management_slug = secrets.token_urlsafe(32) + update_slug = new_bearer_secret() + management_slug = new_bearer_secret() management_hash = hash_lookup_token(management_slug) accepted_at = now if accepted else None with self.connect() as conn: @@ -103,13 +102,13 @@ class AccountsMixin: conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,)) def rotate_update_slug(self, hostname: str) -> AccountRow | None: - update_slug = secrets.token_urlsafe(32) + update_slug = new_bearer_secret() with self.connect() as conn: conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname)) return self.get_account_by_hostname(hostname) def rotate_management_slug(self, hostname: str) -> AccountRow | None: - management_slug = secrets.token_urlsafe(32) + management_slug = new_bearer_secret() management_hash = hash_lookup_token(management_slug) with self.connect() as conn: conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname)) diff --git a/karldns/store/schema.py b/karldns/store/schema.py index a32d6b5..e93d00a 100644 --- a/karldns/store/schema.py +++ b/karldns/store/schema.py @@ -1,10 +1,9 @@ from __future__ import annotations -import secrets import sqlite3 from pathlib import Path -from ..security import hash_lookup_token +from ..security import hash_lookup_token, new_bearer_secret BUSY_TIMEOUT_MS = 10_000 CACHE_SIZE_KIB = 20_000 @@ -160,11 +159,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None: if schema_version < 2: conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL") for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"): - conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0])) + conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (new_bearer_secret(), row[0])) for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"): conn.execute( "UPDATE accounts SET management_hash = ? WHERE hostname = ?", - (hash_lookup_token(secrets.token_urlsafe(32)), row[0]), + (hash_lookup_token(new_bearer_secret()), row[0]), ) _migrate_accounts_schema(conn) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)") diff --git a/karldns/web/templates_management.py b/karldns/web/templates_management.py index fccb63f..5d2f6dc 100644 --- a/karldns/web/templates_management.py +++ b/karldns/web/templates_management.py @@ -162,7 +162,6 @@ def _render_pending_management_page( "management_slug": management_slug, } update_url = service.fritz_update_url(account_for_url) - registration_url = service.registration_url({"management_slug": management_slug}) dashboard_url = service.dashboard_url({"management_slug": management_slug}) hostname = escape(raw_hostname) message_html = _management_message(message) @@ -188,15 +187,13 @@ def _render_pending_management_page(

Save

Your values.

-

This page is private. You can revisit it while the setup is pending. After registration, the dashboard hides secrets by default.

-

Public: hostname and CNAME target. Secret: update URL, register URL, and private dashboard.

+

Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.

+

Public: Domainnamen. Secret: Update-URL and private dashboard.

{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)} {_copy_row("Domainnamen:", raw_hostname, "Public")} - {_copy_row("Register URL:", registration_url, "Secret", mask_secret=False)} {_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)} - {_copy_row("CNAME target:", raw_hostname, "Public")}
@@ -219,7 +216,7 @@ def _render_pending_management_page(

Use your own DNS name.

-

Create a CNAME at your DNS provider that points to {hostname}. KarlDNS only updates this generated hostname.

+

Create a CNAME at your DNS provider that points to {hostname}. This is the same value as Domainnamen.

diff --git a/tests/test_api.py b/tests/test_api.py index 319a3ed..7e13908 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -161,6 +161,9 @@ def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None: dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1] update_path = _path(body["update_url"]) register_path = _path(body["registration_url"]) + assert dashboard_secret.startswith("karl_") + assert update_path.rsplit("/", 1)[1].startswith("karl_") + assert register_path.rsplit("/", 1)[1].startswith("karl_") pending = client.get(f"/api/v1/hostnames/{dashboard_secret}") assert pending.status_code == 200 @@ -214,6 +217,7 @@ def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None: assert rotated.status_code == 200 assert rotated.json()["hostname"] == body["hostname"] assert rotated.json()["update_url"] != body["update_url"] + assert new_update_path.rsplit("/", 1)[1].startswith("karl_") assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404 assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200 diff --git a/tests/test_ui.py b/tests/test_ui.py index 37d9066..7b6c493 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -28,8 +28,9 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) assert response.status_code == 200 assert "/m/" in str(response.url) assert "Update-URL:" in response.text - assert "CNAME target:" in response.text - assert "Register URL:" in response.text + assert "Domainnamen:" in response.text + assert "CNAME target:" not in response.text + assert "Register URL:" not in response.text assert "Router setup." in response.text assert "Use your own DNS name." in response.text assert "Manual updates" in response.text @@ -40,7 +41,7 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) assert "Secret:" in response.text assert ".ddns.example.net" in response.text assert "/api/v1/router/update/" in response.text - assert "/api/v1/router/register/" in response.text + assert "/api/v1/router/register/" not in response.text assert "/m/" in response.text assert 'type="password"' not in response.text assert "Copy setup values" not in response.text @@ -79,8 +80,9 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P assert response.status_code == 200 assert "/m/" in str(response.url) assert "Update-URL:" in response.text - assert "CNAME target:" in response.text - assert "Register URL:" in response.text + assert "Domainnamen:" in response.text + assert "CNAME target:" not in response.text + assert "Register URL:" not in response.text assert "Private dashboard:" in response.text assert "Pending setup" in response.text assert "Register hostname" in response.text @@ -89,8 +91,8 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P assert "Copy setup values" not in response.text assert "Paste this exact URL into the FRITZ!Box" in response.text assert "Copy manual update commands" in response.text - assert response.text.count("copy-field-secret") >= 3 - assert response.text.count("copy-field-public") >= 2 + assert response.text.count("copy-field-secret") >= 2 + assert response.text.count("copy-field-public") >= 1 assert ".ddns.example.net" in response.text assert 'type="password"' not in response.text