personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace plaintext password storage with werkzeug scrypt hashing

- Add _migrate_password_hash() in create_app() to auto-migrate existing
plaintext convey.password → password_hash on startup
- Login now uses check_password_hash instead of == comparison
- Settings API hashes password before writing, strips hash from responses
- GET /api/config returns has_password boolean instead of password value
- No-password error message references 'sol password set'
- Pre-hashed test fixture, new tests for login/migration/settings API
- Doc updates: INSTALL.md, CONVEY.md, JOURNAL.md

+224 -43
+19
apps/settings/routes.py
··· 62 62 # Strip convey secret from API response — never expose signing keys 63 63 if "convey" in config: 64 64 config["convey"].pop("secret", None) 65 + has_pw = bool(config["convey"].pop("password_hash", None)) 66 + config["convey"].pop("password", None) 67 + config["convey"]["has_password"] = has_pw 65 68 66 69 # Add runtime_env - keys available in the running process 67 70 config["runtime_env"] = {k: bool(os.getenv(k)) for k in API_KEY_ENV_VARS} ··· 157 160 changed_fields[key] = {"old": old_value, "new": new_value} 158 161 config[section][key] = new_value 159 162 163 + # Hash password before writing to disk 164 + if section == "convey" and "password" in data: 165 + raw_password = config["convey"].pop("password", "") 166 + if raw_password: 167 + from werkzeug.security import generate_password_hash 168 + 169 + config["convey"]["password_hash"] = generate_password_hash(raw_password) 170 + # If empty, don't touch password_hash — user didn't enter a new one 171 + 160 172 # Handle nested backend configs for transcribe section 161 173 if section == "transcribe": 162 174 for backend_key, allowed_keys in transcribe_nested.items(): ··· 250 262 # Mask env values in response 251 263 if "env" in config: 252 264 config["env"] = {k: bool(v) for k, v in config["env"].items()} 265 + 266 + # Strip sensitive convey fields from response 267 + if "convey" in config: 268 + config["convey"].pop("secret", None) 269 + has_pw = bool(config["convey"].pop("password_hash", None)) 270 + config["convey"].pop("password", None) 271 + config["convey"]["has_password"] = has_pw 253 272 254 273 key_validation = config.get("providers", {}).get("key_validation", {}) 255 274 return jsonify(
+5 -1
apps/settings/workspace.html
··· 2720 2720 2721 2721 // Convey (password) 2722 2722 const convey = config.convey || {}; 2723 - setValue('field-password', convey.password || ''); 2723 + if (convey.has_password) { 2724 + document.getElementById('field-password').placeholder = 'Password set (enter to change)'; 2725 + } else { 2726 + document.getElementById('field-password').placeholder = 'Enter password to protect web access'; 2727 + } 2724 2728 2725 2729 // Support settings 2726 2730 const support = config.support || {};
+26
convey/__init__.py
··· 52 52 return secret 53 53 54 54 55 + def _migrate_password_hash() -> None: 56 + """Migrate plaintext convey.password to hashed password_hash.""" 57 + from werkzeug.security import generate_password_hash 58 + 59 + from think.utils import get_config, get_journal 60 + 61 + config = get_config() 62 + convey = config.get("convey", {}) 63 + 64 + if "password_hash" in convey or "password" not in convey: 65 + return 66 + 67 + plaintext = convey.pop("password") 68 + if plaintext: 69 + convey["password_hash"] = generate_password_hash(plaintext) 70 + 71 + config["convey"] = convey 72 + config_path = Path(get_journal()) / "config" / "journal.json" 73 + config_path.parent.mkdir(parents=True, exist_ok=True) 74 + with open(config_path, "w", encoding="utf-8") as f: 75 + json.dump(config, f, indent=2, ensure_ascii=False) 76 + f.write("\n") 77 + os.chmod(config_path, 0o600) 78 + 79 + 55 80 def create_app(journal: str = "") -> Flask: 56 81 """Create and configure the Convey Flask application.""" 57 82 app = Flask( ··· 72 97 ) 73 98 74 99 app.secret_key = _get_or_create_secret() 100 + _migrate_password_hash() 75 101 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30) 76 102 77 103 # Register root blueprint (login, logout, /, favicon)
+5 -5
convey/cli.py
··· 18 18 logger = logging.getLogger(__name__) 19 19 20 20 21 - def _resolve_config_password() -> str: 22 - """Return the configured Convey password from journal config.""" 21 + def _resolve_config_password_hash() -> str: 22 + """Return the configured Convey password hash from journal config.""" 23 23 from think.utils import get_config 24 24 25 25 try: 26 26 config = get_config() 27 27 convey_config = config.get("convey", {}) 28 - return convey_config.get("password", "") 28 + return convey_config.get("password_hash", "") 29 29 except Exception: 30 30 return "" 31 31 ··· 96 96 logger.info(f"Completed {succeeded}/{ran} maintenance task(s)") 97 97 98 98 app = create_app(journal) 99 - password = _resolve_config_password() 99 + password = _resolve_config_password_hash() 100 100 if password: 101 101 logger.info("Password authentication enabled") 102 102 else: 103 103 logger.warning( 104 - "No password configured - add to config/journal.json to enable authentication" 104 + "No password configured - run 'sol password set' to enable authentication" 105 105 ) 106 106 107 107 # Write port to health directory for discovery by other tools
+8 -11
convey/root.py
··· 18 18 session, 19 19 url_for, 20 20 ) 21 + from werkzeug.security import check_password_hash 21 22 22 23 from think.cluster import cluster_segments 23 24 from think.utils import day_dirs, get_config 24 25 25 26 26 - def _get_password() -> str: 27 - """Get current password from config, reloading on each call.""" 27 + def _get_password_hash() -> str: 28 + """Get current password hash from config, reloading on each call.""" 28 29 try: 29 30 config = get_config() 30 31 convey_config = config.get("convey", {}) 31 - return convey_config.get("password", "") 32 + return convey_config.get("password_hash", "") 32 33 except Exception: 33 34 return "" 34 35 ··· 77 78 @bp.route("/login", methods=["GET", "POST"]) 78 79 def login() -> Any: 79 80 # Re-check password from config on each request 80 - password = _get_password() 81 + password_hash = _get_password_hash() 81 82 82 83 # If no password is configured, show error page 83 - if not password: 84 - error = ( 85 - "No password configured. Please add a password to your journal " 86 - "config at config/journal.json:\n\n" 87 - '{\n "convey": {\n "password": "your-password-here"\n }\n}' 88 - ) 84 + if not password_hash: 85 + error = "No password configured. Run 'sol password set' to set one." 89 86 return render_template("login.html", error=error, no_password=True) 90 87 91 88 error = None 92 89 if request.method == "POST": 93 - if request.form.get("password") == password: 90 + if check_password_hash(password_hash, request.form.get("password", "")): 94 91 session["logged_in"] = True 95 92 session.permanent = True 96 93 return redirect(url_for("root.index"))
+1 -6
convey/templates/login.html
··· 35 35 <h2>sign in to solstone</h2> 36 36 {% if error %} 37 37 <div style="color:red;margin:1em 0;text-align:left;"> 38 - {% if no_password %} 39 - <p style="font-weight:bold;margin-bottom:1em;">{{ error.split('\n\n')[0] }}</p> 40 - <pre style="background:#f5f5f5;padding:1em;text-align:left;border-radius:4px;">{{ error.split('\n\n')[1] }}</pre> 41 - {% else %} 42 - <p>{{ error }}</p> 43 - {% endif %} 38 + <p>{{ error }}</p> 44 39 </div> 45 40 {% endif %} 46 41 {% if not no_password %}
+6 -8
docs/CONVEY.md
··· 18 18 19 19 ### Authentication 20 20 21 - Password authentication is configured through the journal config at `config/journal.json`: 21 + Password authentication is configured via the CLI: 22 22 23 - ```json 24 - { 25 - "convey": { 26 - "password": "your-password-here" 27 - } 28 - } 23 + ```bash 24 + sol password set 29 25 ``` 30 26 31 - A password must be configured to use the application. If no password is set, the login page will display an error with configuration instructions. 27 + When a password is set, it is stored as a secure hash in `config/journal.json` under `convey.password_hash`. 28 + 29 + If no password is set, the login page will prompt you to run `sol password set`. 32 30 33 31 ## Architecture 34 32
+4 -8
docs/INSTALL.md
··· 71 71 mkdir -p journal/config 72 72 cat > journal/config/journal.json << 'EOF' 73 73 { 74 - "convey": { 75 - "password": "your-password-here" 76 - }, 74 + "convey": {}, 77 75 "env": { 78 76 "GOOGLE_API_KEY": "your-key-here" 79 77 } ··· 82 80 chmod 600 journal/config/journal.json 83 81 ``` 84 82 85 - Replace `your-password-here` with a password for the web interface, and `your-key-here` with your Google AI API key. 83 + Run `sol password set` to configure web authentication. Replace `your-key-here` with your Google AI API key. 86 84 87 85 ### Google AI (Gemini) - Required 88 86 ··· 109 107 110 108 ```json 111 109 { 112 - "convey": { 113 - "password": "your-password" 114 - }, 110 + "convey": {}, 115 111 "env": { 116 112 "GOOGLE_API_KEY": "your-gemini-key", 117 113 "OPENAI_API_KEY": "your-openai-key", ··· 120 116 } 121 117 ``` 122 118 123 - **Important:** `journal.json` contains your API keys and password. It should always have restricted permissions (`chmod 600`). 119 + **Important:** `journal.json` contains your API keys and credentials. It should always have restricted permissions (`chmod 600`). 124 120 125 121 --- 126 122
+2 -2
docs/JOURNAL.md
··· 128 128 ```json 129 129 { 130 130 "convey": { 131 - "password": "your-password-here" 131 + "password_hash": "<set via sol password set>" 132 132 } 133 133 } 134 134 ``` 135 135 136 136 Fields: 137 - - `password` (string) – Password for accessing the convey web application. When set, owners must authenticate before accessing the journal interface. 137 + - `password_hash` (string) – Hashed password for accessing the convey web application. Set via `sol password set`. 138 138 139 139 **UI Preferences:** The separate `config/convey.json` file stores UI/UX personalization (facet/app ordering, selected facet). All fields optional: 140 140
+1 -1
tests/baselines/api/settings/config.json
··· 6 6 "proposal_count": 0 7 7 }, 8 8 "convey": { 9 - "password": "test123" 9 + "has_password": true 10 10 }, 11 11 "describe": { 12 12 "redact": [
+1 -1
tests/fixtures/journal/config/journal.json
··· 14 14 "timezone": "America/Denver" 15 15 }, 16 16 "convey": { 17 - "password": "test123", 17 + "password_hash": "scrypt:32768:8:1$ceTJLGRcxYTqVQ4n$74a88b364046ab7ca627df875f27b1fb50994d4311ff8c86393bd7d32eaac303c81f165e79a99164931457846b4e702ac6cb38b877871cb4fadf1f70937bbfba", 18 18 "secret": "test-fixture-secret-do-not-use-in-production" 19 19 }, 20 20 "transcribe": {
+146
tests/test_password.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for password hashing: login, migration, and settings API.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import shutil 10 + from pathlib import Path 11 + 12 + import pytest 13 + from werkzeug.security import check_password_hash 14 + 15 + from convey import create_app 16 + 17 + 18 + @pytest.fixture 19 + def journal_dir(tmp_path, monkeypatch): 20 + """Copy test fixture to temp dir for mutation tests.""" 21 + src = Path(__file__).resolve().parent / "fixtures" / "journal" 22 + dst = tmp_path / "journal" 23 + shutil.copytree(src, dst) 24 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) 25 + return dst 26 + 27 + 28 + @pytest.fixture 29 + def client(journal_dir): 30 + app = create_app(str(journal_dir)) 31 + app.config["TESTING"] = True 32 + return app.test_client() 33 + 34 + 35 + def _read_config(journal_dir): 36 + return json.loads((journal_dir / "config" / "journal.json").read_text()) 37 + 38 + 39 + class TestLogin: 40 + def test_correct_password(self, client): 41 + resp = client.post("/login", data={"password": "test123"}) 42 + assert resp.status_code == 302 43 + 44 + def test_wrong_password(self, client): 45 + resp = client.post("/login", data={"password": "wrong"}) 46 + assert resp.status_code == 200 47 + assert b"Invalid password" in resp.data 48 + 49 + def test_no_password_configured(self, journal_dir, monkeypatch): 50 + config = _read_config(journal_dir) 51 + config["convey"].pop("password_hash", None) 52 + config["convey"].pop("password", None) 53 + (journal_dir / "config" / "journal.json").write_text( 54 + json.dumps(config, indent=2) 55 + ) 56 + app = create_app(str(journal_dir)) 57 + app.config["TESTING"] = True 58 + client = app.test_client() 59 + resp = client.get("/login") 60 + assert b"sol password set" in resp.data 61 + 62 + 63 + class TestMigration: 64 + def test_plaintext_migrated_to_hash(self, tmp_path, monkeypatch): 65 + """Plaintext password is hashed and old key removed on app creation.""" 66 + src = Path(__file__).resolve().parent / "fixtures" / "journal" 67 + dst = tmp_path / "journal" 68 + shutil.copytree(src, dst) 69 + config_path = dst / "config" / "journal.json" 70 + config = json.loads(config_path.read_text()) 71 + config["convey"].pop("password_hash", None) 72 + config["convey"]["password"] = "migrate-me" 73 + config_path.write_text(json.dumps(config, indent=2)) 74 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) 75 + 76 + create_app(str(dst)) 77 + 78 + config = json.loads(config_path.read_text()) 79 + assert "password" not in config["convey"] 80 + assert "password_hash" in config["convey"] 81 + assert check_password_hash(config["convey"]["password_hash"], "migrate-me") 82 + 83 + def test_empty_password_removed(self, tmp_path, monkeypatch): 84 + """Empty plaintext password is removed, not hashed.""" 85 + src = Path(__file__).resolve().parent / "fixtures" / "journal" 86 + dst = tmp_path / "journal" 87 + shutil.copytree(src, dst) 88 + config_path = dst / "config" / "journal.json" 89 + config = json.loads(config_path.read_text()) 90 + config["convey"].pop("password_hash", None) 91 + config["convey"]["password"] = "" 92 + config_path.write_text(json.dumps(config, indent=2)) 93 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst)) 94 + 95 + create_app(str(dst)) 96 + 97 + config = json.loads(config_path.read_text()) 98 + assert "password" not in config["convey"] 99 + assert "password_hash" not in config["convey"] 100 + 101 + def test_already_migrated_skipped(self, journal_dir): 102 + """If password_hash exists, migration is a no-op.""" 103 + config_before = _read_config(journal_dir) 104 + hash_before = config_before["convey"]["password_hash"] 105 + 106 + create_app(str(journal_dir)) 107 + 108 + config_after = _read_config(journal_dir) 109 + assert config_after["convey"]["password_hash"] == hash_before 110 + 111 + 112 + class TestSettingsAPI: 113 + def test_get_config_strips_password(self, client): 114 + """GET /app/settings/api/config must not return password or password_hash.""" 115 + resp = client.get("/app/settings/api/config") 116 + data = resp.get_json() 117 + convey = data.get("convey", {}) 118 + assert "password" not in convey 119 + assert "password_hash" not in convey 120 + assert convey.get("has_password") is True 121 + 122 + def test_put_hashes_password(self, client, journal_dir): 123 + """PUT with convey.password hashes before writing to disk.""" 124 + resp = client.put( 125 + "/app/settings/api/config", 126 + json={"section": "convey", "data": {"password": "new-secret"}}, 127 + content_type="application/json", 128 + ) 129 + assert resp.status_code == 200 130 + config = _read_config(journal_dir) 131 + assert "password" not in config["convey"] 132 + assert check_password_hash(config["convey"]["password_hash"], "new-secret") 133 + 134 + def test_put_empty_password_skipped(self, client, journal_dir): 135 + """PUT with empty password does not overwrite existing hash.""" 136 + config_before = _read_config(journal_dir) 137 + hash_before = config_before["convey"]["password_hash"] 138 + 139 + resp = client.put( 140 + "/app/settings/api/config", 141 + json={"section": "convey", "data": {"password": ""}}, 142 + content_type="application/json", 143 + ) 144 + assert resp.status_code == 200 145 + config_after = _read_config(journal_dir) 146 + assert config_after["convey"]["password_hash"] == hash_before