From 0c03348c8841d1ebb7d8460cff22734ee97f3392 Mon Sep 17 00:00:00 2001 From: Ignace Date: Sun, 19 Jul 2026 09:58:27 +0200 Subject: [PATCH] better secrets admin --- README.md | 49 ++++++++------------------ initialize_data.py | 21 ++++-------- lapp.py | 28 +++------------ requirement.txt | 1 + secrets_config.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ test.py | 9 +++-- 6 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 secrets_config.py diff --git a/README.md b/README.md index 3d998bb..fe81cff 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,24 @@ install ## Secrets and configuration -LAPP does not store runtime secrets in the source code. Configure them with -environment variables in production: +LAPP stores its private configuration in `instance/secrets.yaml`. On first +startup, missing values are generated automatically and the file permissions are +set to `0600`. -```sh -export LAPP_SECRET_KEY="replace-with-a-long-random-secret" -export LAPP_APPLICATION_KEY="replace-with-another-long-random-secret" -export LAPP_DATABASE_URI="sqlite:///app.db" -``` +The file contains the Flask session key, API application key, database URI, +initial account credentials, initial group secrets, and the Fernet key used by +`test.py`. To change the database, edit `runtime.database_uri` in this file. -`LAPP_SECRET_KEY` signs Flask sessions and auto-login cookies. Keep it stable: -changing it logs users out and invalidates existing auto-login cookies. +Keep `runtime.secret_key` stable: changing it logs users out and invalidates +existing auto-login cookies. `runtime.application_key` is used by +`/api/validate_key`. -`LAPP_APPLICATION_KEY` is used by `/api/validate_key`. - -`LAPP_DATABASE_URI` is optional. If it is not set, LAPP uses `sqlite:///app.db`. - -For local development, if `LAPP_SECRET_KEY` or `LAPP_APPLICATION_KEY` is not set, -the app creates stable random secrets in: - -```text -instance/secret_key -instance/application_key -``` - -The `instance/` directory is ignored by git, so these generated secrets should -not be committed. +The entire `instance/` directory is ignored by Git. Back up `secrets.yaml` +securely and never commit or share it. ## Initial data secrets `initialize_data.py` creates initial users and groups only when the database has -no users yet. You can provide the initial passwords and group secrets with: - -```sh -export LAPP_INITIAL_ADMIN_PASSWORD="replace-me" -export LAPP_INITIAL_ADMIN_GROUP="replace-me" -export LAPP_INITIAL_USER_PASSWORD="replace-me" -export LAPP_INITIAL_USER_GROUP="replace-me" -./venv/bin/python initialize_data.py -``` - -If these variables are not set, `initialize_data.py` generates random values and -prints them once when it creates the initial data. +no users yet. Set the values under `initial_data` in `instance/secrets.yaml` +before running it, or use the securely generated defaults. The script prints the +location of the credentials when it creates the initial data. diff --git a/initialize_data.py b/initialize_data.py index 07e48bd..ec60469 100644 --- a/initialize_data.py +++ b/initialize_data.py @@ -1,11 +1,7 @@ from lapp import create_app -import os -import secrets import sqlalchemy as sa from models import db, User, ListOfItems, Shared, Item, Group - -def initial_secret(env_name): - return os.environ.get(env_name) or secrets.token_urlsafe(18) +from secrets_config import load_secrets if __name__ == "__main__": app = create_app() @@ -13,10 +9,11 @@ if __name__ == "__main__": query = sa.select(User) users = db.session.scalars(query).all() if len(users) == 0: - admin_group_secret = initial_secret("LAPP_INITIAL_ADMIN_GROUP") - user_group_secret = initial_secret("LAPP_INITIAL_USER_GROUP") - admin_password = initial_secret("LAPP_INITIAL_ADMIN_PASSWORD") - user_password = initial_secret("LAPP_INITIAL_USER_PASSWORD") + initial_data = load_secrets(app.instance_path)["initial_data"] + admin_group_secret = initial_data["admin_group"] + user_group_secret = initial_data["user_group"] + admin_password = initial_data["admin_password"] + user_password = initial_data["user_password"] g = Group(secret=admin_group_secret) db.session.add(g) @@ -30,11 +27,7 @@ if __name__ == "__main__": db.session.add(user1) db.session.commit() - print("Created initial credentials:") - print(f"admin password: {admin_password}") - print(f"admin group secret: {admin_group_secret}") - print(f"ignace password: {user_password}") - print(f"ignace group secret: {user_group_secret}") + print("Created initial credentials from instance/secrets.yaml") lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True) db.session.add(lol1) diff --git a/lapp.py b/lapp.py index 5d88dc1..7f1e84b 100644 --- a/lapp.py +++ b/lapp.py @@ -1,6 +1,3 @@ -import os -import secrets -from pathlib import Path from flask import Flask, send_from_directory, url_for from flask_login import LoginManager from models import db @@ -11,31 +8,16 @@ from lists import lists_bp from items import items_bp from api import api_bp from groups import groups_bp +from secrets_config import load_secrets login_manager = LoginManager() -def load_secret(app, env_name, filename): - secret = os.environ.get(env_name) - if secret: - return secret - - instance_path = Path(app.instance_path) - instance_path.mkdir(parents=True, exist_ok=True) - secret_path = instance_path / filename - - if secret_path.exists(): - return secret_path.read_text(encoding="utf-8").strip() - - secret = secrets.token_urlsafe(48) - secret_path.write_text(secret, encoding="utf-8") - secret_path.chmod(0o600) - return secret - def create_app(): app = Flask(__name__, instance_relative_config=True) - app.config["SECRET_KEY"] = load_secret(app, "LAPP_SECRET_KEY", "secret_key") - app.config["APPLICATION_KEY"] = load_secret(app, "LAPP_APPLICATION_KEY", "application_key") - app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("LAPP_DATABASE_URI", "sqlite:///app.db") + secret_values = load_secrets(app.instance_path) + app.config["SECRET_KEY"] = secret_values["runtime"]["secret_key"] + app.config["APPLICATION_KEY"] = secret_values["runtime"]["application_key"] + app.config["SQLALCHEMY_DATABASE_URI"] = secret_values["runtime"]["database_uri"] db.init_app(app) login_manager.init_app(app) diff --git a/requirement.txt b/requirement.txt index 236ba38..f306269 100644 --- a/requirement.txt +++ b/requirement.txt @@ -2,3 +2,4 @@ Flask>=3.0,<4.0 Flask-Login>=0.6,<1.0 Flask-SQLAlchemy>=3.1,<4.0 cryptography>=42,<46 +PyYAML>=6.0,<7.0 diff --git a/secrets_config.py b/secrets_config.py new file mode 100644 index 0000000..e6e04ab --- /dev/null +++ b/secrets_config.py @@ -0,0 +1,85 @@ +import base64 +import secrets +from pathlib import Path + +import yaml + + +def _fernet_key(): + return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii") + + +def _default_secrets(): + return { + "runtime": { + "secret_key": secrets.token_urlsafe(48), + "application_key": secrets.token_urlsafe(48), + "database_uri": "sqlite:///app.db", + }, + "initial_data": { + "admin_password": secrets.token_urlsafe(18), + "admin_group": secrets.token_urlsafe(18), + "user_password": secrets.token_urlsafe(18), + "user_group": secrets.token_urlsafe(18), + }, + "fernet": { + "key": _fernet_key(), + }, + } + + +def _merge_missing(target, defaults): + changed = False + for key, value in defaults.items(): + if isinstance(value, dict): + current = target.get(key) + if not isinstance(current, dict): + target[key] = {} + current = target[key] + changed = True + if _merge_missing(current, value): + changed = True + elif not target.get(key): + target[key] = value + changed = True + return changed + + +def load_secrets(instance_path): + instance_dir = Path(instance_path) + instance_dir.mkdir(parents=True, exist_ok=True) + secrets_path = instance_dir / "secrets.yaml" + existed = secrets_path.exists() + if existed: + secrets_path.chmod(0o600) + else: + secrets_path.touch(mode=0o600) + + if existed: + loaded = yaml.safe_load(secrets_path.read_text(encoding="utf-8")) or {} + if not isinstance(loaded, dict): + raise ValueError(f"{secrets_path} must contain a YAML mapping") + else: + loaded = {} + + defaults = _default_secrets() + legacy_files = { + "secret_key": instance_dir / "secret_key", + "application_key": instance_dir / "application_key", + } + migrated_paths = [] + for key, legacy_path in legacy_files.items(): + if legacy_path.exists() and not loaded.get("runtime", {}).get(key): + defaults["runtime"][key] = legacy_path.read_text(encoding="utf-8").strip() + migrated_paths.append(legacy_path) + + changed = _merge_missing(loaded, defaults) + if changed or not existed: + secrets_path.write_text( + yaml.safe_dump(loaded, sort_keys=False), + encoding="utf-8", + ) + secrets_path.chmod(0o600) + for legacy_path in migrated_paths: + legacy_path.unlink() + return loaded diff --git a/test.py b/test.py index ac95761..5eaa777 100644 --- a/test.py +++ b/test.py @@ -1,11 +1,14 @@ import pickle +from pathlib import Path + from cryptography.fernet import Fernet +from secrets_config import load_secrets # ===================================================== # SECRET KEY (only your program has this) # ===================================================== -# Generate once using: Fernet.generate_key() -_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs=' +# The key is generated once and stored outside Git in instance/secrets.yaml. +_SECRET_KEY = load_secrets(Path(__file__).parent / "instance")["fernet"]["key"].encode("ascii") fernet = Fernet(_SECRET_KEY) @@ -56,4 +59,4 @@ print("Type Before :- ",type(bytes_data)) print(string_data) -print("Type After :- ",type(string_data)) \ No newline at end of file +print("Type After :- ",type(string_data))