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