diff --git a/apps/settings/routes.py b/apps/settings/routes.py index 21332fdea..fe963a6fd 100644 --- a/apps/settings/routes.py +++ b/apps/settings/routes.py @@ -62,6 +62,9 @@ def get_config() -> Any: # Strip convey secret from API response — never expose signing keys if "convey" in config: config["convey"].pop("secret", None) + has_pw = bool(config["convey"].pop("password_hash", None)) + config["convey"].pop("password", None) + config["convey"]["has_password"] = has_pw # Add runtime_env - keys available in the running process config["runtime_env"] = {k: bool(os.getenv(k)) for k in API_KEY_ENV_VARS} @@ -157,6 +160,15 @@ def update_config() -> Any: changed_fields[key] = {"old": old_value, "new": new_value} config[section][key] = new_value + # Hash password before writing to disk + if section == "convey" and "password" in data: + raw_password = config["convey"].pop("password", "") + if raw_password: + from werkzeug.security import generate_password_hash + + config["convey"]["password_hash"] = generate_password_hash(raw_password) + # If empty, don't touch password_hash — user didn't enter a new one + # Handle nested backend configs for transcribe section if section == "transcribe": for backend_key, allowed_keys in transcribe_nested.items(): @@ -251,6 +263,13 @@ def update_config() -> Any: if "env" in config: config["env"] = {k: bool(v) for k, v in config["env"].items()} + # Strip sensitive convey fields from response + if "convey" in config: + config["convey"].pop("secret", None) + has_pw = bool(config["convey"].pop("password_hash", None)) + config["convey"].pop("password", None) + config["convey"]["has_password"] = has_pw + key_validation = config.get("providers", {}).get("key_validation", {}) return jsonify( {"success": True, "config": config, "key_validation": key_validation} diff --git a/apps/settings/workspace.html b/apps/settings/workspace.html index 9dd42921d..6091b3925 100644 --- a/apps/settings/workspace.html +++ b/apps/settings/workspace.html @@ -2720,7 +2720,11 @@ function populateFields(config) { // Convey (password) const convey = config.convey || {}; - setValue('field-password', convey.password || ''); + if (convey.has_password) { + document.getElementById('field-password').placeholder = 'Password set (enter to change)'; + } else { + document.getElementById('field-password').placeholder = 'Enter password to protect web access'; + } // Support settings const support = config.support || {}; diff --git a/convey/__init__.py b/convey/__init__.py index 782f779d5..27cb1f02c 100644 --- a/convey/__init__.py +++ b/convey/__init__.py @@ -52,6 +52,31 @@ def _get_or_create_secret() -> str: return secret +def _migrate_password_hash() -> None: + """Migrate plaintext convey.password to hashed password_hash.""" + from werkzeug.security import generate_password_hash + + from think.utils import get_config, get_journal + + config = get_config() + convey = config.get("convey", {}) + + if "password_hash" in convey or "password" not in convey: + return + + plaintext = convey.pop("password") + if plaintext: + convey["password_hash"] = generate_password_hash(plaintext) + + config["convey"] = convey + config_path = Path(get_journal()) / "config" / "journal.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + f.write("\n") + os.chmod(config_path, 0o600) + + def create_app(journal: str = "") -> Flask: """Create and configure the Convey Flask application.""" app = Flask( @@ -72,6 +97,7 @@ def create_app(journal: str = "") -> Flask: ) app.secret_key = _get_or_create_secret() + _migrate_password_hash() app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) # Register root blueprint (login, logout, /, favicon) diff --git a/convey/cli.py b/convey/cli.py index 9585172a6..56d4c9009 100644 --- a/convey/cli.py +++ b/convey/cli.py @@ -18,14 +18,14 @@ from .bridge import start_bridge, stop_bridge logger = logging.getLogger(__name__) -def _resolve_config_password() -> str: - """Return the configured Convey password from journal config.""" +def _resolve_config_password_hash() -> str: + """Return the configured Convey password hash from journal config.""" from think.utils import get_config try: config = get_config() convey_config = config.get("convey", {}) - return convey_config.get("password", "") + return convey_config.get("password_hash", "") except Exception: return "" @@ -96,12 +96,12 @@ def main() -> None: logger.info(f"Completed {succeeded}/{ran} maintenance task(s)") app = create_app(journal) - password = _resolve_config_password() + password = _resolve_config_password_hash() if password: logger.info("Password authentication enabled") else: logger.warning( - "No password configured - add to config/journal.json to enable authentication" + "No password configured - run 'sol password set' to enable authentication" ) # Write port to health directory for discovery by other tools diff --git a/convey/root.py b/convey/root.py index 496bb89e9..b594dd496 100644 --- a/convey/root.py +++ b/convey/root.py @@ -18,17 +18,18 @@ from flask import ( session, url_for, ) +from werkzeug.security import check_password_hash from think.cluster import cluster_segments from think.utils import day_dirs, get_config -def _get_password() -> str: - """Get current password from config, reloading on each call.""" +def _get_password_hash() -> str: + """Get current password hash from config, reloading on each call.""" try: config = get_config() convey_config = config.get("convey", {}) - return convey_config.get("password", "") + return convey_config.get("password_hash", "") except Exception: return "" @@ -77,20 +78,16 @@ def require_login() -> Any: @bp.route("/login", methods=["GET", "POST"]) def login() -> Any: # Re-check password from config on each request - password = _get_password() + password_hash = _get_password_hash() # If no password is configured, show error page - if not password: - error = ( - "No password configured. Please add a password to your journal " - "config at config/journal.json:\n\n" - '{\n "convey": {\n "password": "your-password-here"\n }\n}' - ) + if not password_hash: + error = "No password configured. Run 'sol password set' to set one." return render_template("login.html", error=error, no_password=True) error = None if request.method == "POST": - if request.form.get("password") == password: + if check_password_hash(password_hash, request.form.get("password", "")): session["logged_in"] = True session.permanent = True return redirect(url_for("root.index")) diff --git a/convey/templates/login.html b/convey/templates/login.html index 072ef64b0..a58caf511 100644 --- a/convey/templates/login.html +++ b/convey/templates/login.html @@ -35,12 +35,7 @@

sign in to solstone

{% if error %}
- {% if no_password %} -

{{ error.split('\n\n')[0] }}

-
{{ error.split('\n\n')[1] }}
- {% else %} -

{{ error }}

- {% endif %} +

{{ error }}

{% endif %} {% if not no_password %} diff --git a/docs/CONVEY.md b/docs/CONVEY.md index 33666c4c0..8ef6b9895 100644 --- a/docs/CONVEY.md +++ b/docs/CONVEY.md @@ -18,17 +18,15 @@ convey ### Authentication -Password authentication is configured through the journal config at `config/journal.json`: - -```json -{ - "convey": { - "password": "your-password-here" - } -} +Password authentication is configured via the CLI: + +```bash +sol password set ``` -A password must be configured to use the application. If no password is set, the login page will display an error with configuration instructions. +When a password is set, it is stored as a secure hash in `config/journal.json` under `convey.password_hash`. + +If no password is set, the login page will prompt you to run `sol password set`. ## Architecture diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0a65a564e..e005dca1e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -71,9 +71,7 @@ Create the config file: mkdir -p journal/config cat > journal/config/journal.json << 'EOF' { - "convey": { - "password": "your-password-here" - }, + "convey": {}, "env": { "GOOGLE_API_KEY": "your-key-here" } @@ -82,7 +80,7 @@ EOF chmod 600 journal/config/journal.json ``` -Replace `your-password-here` with a password for the web interface, and `your-key-here` with your Google AI API key. +Run `sol password set` to configure web authentication. Replace `your-key-here` with your Google AI API key. ### Google AI (Gemini) - Required @@ -109,9 +107,7 @@ For transcribing imported audio files. Sign up at [Rev.ai](https://www.rev.ai/), ```json { - "convey": { - "password": "your-password" - }, + "convey": {}, "env": { "GOOGLE_API_KEY": "your-gemini-key", "OPENAI_API_KEY": "your-openai-key", @@ -120,7 +116,7 @@ For transcribing imported audio files. Sign up at [Rev.ai](https://www.rev.ai/), } ``` -**Important:** `journal.json` contains your API keys and password. It should always have restricted permissions (`chmod 600`). +**Important:** `journal.json` contains your API keys and credentials. It should always have restricted permissions (`chmod 600`). --- diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index bfec0df6c..e3ffee910 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -128,13 +128,13 @@ The `convey` block contains settings for the web application: ```json { "convey": { - "password": "your-password-here" + "password_hash": "" } } ``` Fields: -- `password` (string) – Password for accessing the convey web application. When set, owners must authenticate before accessing the journal interface. +- `password_hash` (string) – Hashed password for accessing the convey web application. Set via `sol password set`. **UI Preferences:** The separate `config/convey.json` file stores UI/UX personalization (facet/app ordering, selected facet). All fields optional: diff --git a/tests/baselines/api/settings/config.json b/tests/baselines/api/settings/config.json index 23da26cb4..490012246 100644 --- a/tests/baselines/api/settings/config.json +++ b/tests/baselines/api/settings/config.json @@ -6,7 +6,7 @@ "proposal_count": 0 }, "convey": { - "password": "test123" + "has_password": true }, "describe": { "redact": [ diff --git a/tests/fixtures/journal/config/journal.json b/tests/fixtures/journal/config/journal.json index 1fe0b7629..de955a439 100644 --- a/tests/fixtures/journal/config/journal.json +++ b/tests/fixtures/journal/config/journal.json @@ -14,7 +14,7 @@ "timezone": "America/Denver" }, "convey": { - "password": "test123", + "password_hash": "scrypt:32768:8:1$ceTJLGRcxYTqVQ4n$74a88b364046ab7ca627df875f27b1fb50994d4311ff8c86393bd7d32eaac303c81f165e79a99164931457846b4e702ac6cb38b877871cb4fadf1f70937bbfba", "secret": "test-fixture-secret-do-not-use-in-production" }, "transcribe": { diff --git a/tests/test_password.py b/tests/test_password.py new file mode 100644 index 000000000..2966297f2 --- /dev/null +++ b/tests/test_password.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for password hashing: login, migration, and settings API.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from werkzeug.security import check_password_hash + +from convey import create_app + + +@pytest.fixture +def journal_dir(tmp_path, monkeypatch): + """Copy test fixture to temp dir for mutation tests.""" + src = Path(__file__).resolve().parent / "fixtures" / "journal" + dst = tmp_path / "journal" + shutil.copytree(src, dst) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) + return dst + + +@pytest.fixture +def client(journal_dir): + app = create_app(str(journal_dir)) + app.config["TESTING"] = True + return app.test_client() + + +def _read_config(journal_dir): + return json.loads((journal_dir / "config" / "journal.json").read_text()) + + +class TestLogin: + def test_correct_password(self, client): + resp = client.post("/login", data={"password": "test123"}) + assert resp.status_code == 302 + + def test_wrong_password(self, client): + resp = client.post("/login", data={"password": "wrong"}) + assert resp.status_code == 200 + assert b"Invalid password" in resp.data + + def test_no_password_configured(self, journal_dir, monkeypatch): + config = _read_config(journal_dir) + config["convey"].pop("password_hash", None) + config["convey"].pop("password", None) + (journal_dir / "config" / "journal.json").write_text( + json.dumps(config, indent=2) + ) + app = create_app(str(journal_dir)) + app.config["TESTING"] = True + client = app.test_client() + resp = client.get("/login") + assert b"sol password set" in resp.data + + +class TestMigration: + def test_plaintext_migrated_to_hash(self, tmp_path, monkeypatch): + """Plaintext password is hashed and old key removed on app creation.""" + src = Path(__file__).resolve().parent / "fixtures" / "journal" + dst = tmp_path / "journal" + shutil.copytree(src, dst) + config_path = dst / "config" / "journal.json" + config = json.loads(config_path.read_text()) + config["convey"].pop("password_hash", None) + config["convey"]["password"] = "migrate-me" + config_path.write_text(json.dumps(config, indent=2)) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) + + create_app(str(dst)) + + config = json.loads(config_path.read_text()) + assert "password" not in config["convey"] + assert "password_hash" in config["convey"] + assert check_password_hash(config["convey"]["password_hash"], "migrate-me") + + def test_empty_password_removed(self, tmp_path, monkeypatch): + """Empty plaintext password is removed, not hashed.""" + src = Path(__file__).resolve().parent / "fixtures" / "journal" + dst = tmp_path / "journal" + shutil.copytree(src, dst) + config_path = dst / "config" / "journal.json" + config = json.loads(config_path.read_text()) + config["convey"].pop("password_hash", None) + config["convey"]["password"] = "" + config_path.write_text(json.dumps(config, indent=2)) + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) + + create_app(str(dst)) + + config = json.loads(config_path.read_text()) + assert "password" not in config["convey"] + assert "password_hash" not in config["convey"] + + def test_already_migrated_skipped(self, journal_dir): + """If password_hash exists, migration is a no-op.""" + config_before = _read_config(journal_dir) + hash_before = config_before["convey"]["password_hash"] + + create_app(str(journal_dir)) + + config_after = _read_config(journal_dir) + assert config_after["convey"]["password_hash"] == hash_before + + +class TestSettingsAPI: + def test_get_config_strips_password(self, client): + """GET /app/settings/api/config must not return password or password_hash.""" + resp = client.get("/app/settings/api/config") + data = resp.get_json() + convey = data.get("convey", {}) + assert "password" not in convey + assert "password_hash" not in convey + assert convey.get("has_password") is True + + def test_put_hashes_password(self, client, journal_dir): + """PUT with convey.password hashes before writing to disk.""" + resp = client.put( + "/app/settings/api/config", + json={"section": "convey", "data": {"password": "new-secret"}}, + content_type="application/json", + ) + assert resp.status_code == 200 + config = _read_config(journal_dir) + assert "password" not in config["convey"] + assert check_password_hash(config["convey"]["password_hash"], "new-secret") + + def test_put_empty_password_skipped(self, client, journal_dir): + """PUT with empty password does not overwrite existing hash.""" + config_before = _read_config(journal_dir) + hash_before = config_before["convey"]["password_hash"] + + resp = client.put( + "/app/settings/api/config", + json={"section": "convey", "data": {"password": ""}}, + content_type="application/json", + ) + assert resp.status_code == 200 + config_after = _read_config(journal_dir) + assert config_after["convey"]["password_hash"] == hash_before