diff --git a/.env.example b/.env.example index d68bb71..4e32629 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ APP_HEALTHCHECK_RETRIES=3 LINKLOG_APP_NAME=LinkLog LINKLOG_VERSION=0.1.0 LINKLOG_SECRET_KEY=replace-with-a-long-random-secret +LINKLOG_DATA_ENCRYPTION_KEY=generate-with-python-cryptography-fernet-key LINKLOG_TOKEN_EXPIRY_DAYS=30 LINKLOG_PUBLIC_URL=localhost LINKLOG_SMTP_HOST= diff --git a/README.md b/README.md index 74956df..eb346a8 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ The main configurable values are: | Variable | Purpose | Default | | --- | --- | --- | | `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker | +| `LINKLOG_DATA_ENCRYPTION_KEY` | Fernet key for encrypting SMTP, Mastodon, and OTP secrets at rest | required in Docker | | `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` | | `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` | | `LINKLOG_PUBLIC_URL` | Public hostname used by Traefik and expanded to a callback URL by the backend | `localhost` | @@ -189,6 +190,8 @@ When a verified user enters the wrong password, LinkLog keeps the response gener The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself. +`LINKLOG_DATA_ENCRYPTION_KEY` must be a Fernet key kept outside the database. Generate one with a Python environment that has `cryptography` installed, for example `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`, then store it in `.env` or a protected deployment secret. Losing this key makes encrypted SMTP, Mastodon, and OTP values unrecoverable. Existing plaintext values from earlier versions should be rotated by saving them again after configuring the key. + Build and start the application: ```sh diff --git a/Security-audit.md b/Security-audit.md index 1a6fd40..0315820 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -65,13 +65,16 @@ These findings are prioritized below. Severity describes the potential security ### SA-003: Sensitive secrets stored in plaintext SQLite -**Severity:** High -**Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table. +**Severity:** High, remediated in current worktree for newly written secrets +**Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table. New writes are encrypted, but legacy plaintext rows require rotation. **Impact:** Read access to the database exposes SMTP credentials, Mastodon posting authority, OAuth client secrets, and TOTP seeds. TOTP seeds cannot be changed by a user who loses the database copy. Database backups therefore contain reusable credentials, not just application data. -**Recommendation:** Encrypt secrets at rest using an external secret-management system or an application encryption key held outside the database. At minimum, use a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage, restrict file and volume permissions, and document backup key management. Rotate all credentials after a suspected database disclosure. Continue omitting secrets from API responses. +**Current state:** Newly stored SMTP passwords, Mastodon OAuth client secrets and access tokens, and TOTP seeds are encrypted with Fernet using `LINKLOG_DATA_ENCRYPTION_KEY`. The key is required in Docker and is not stored in SQLite. The user plugin API no longer returns the Mastodon access token. +**Residual impact:** Existing plaintext secrets require a controlled read-and-save rotation after the key is configured. Lost encryption keys make stored secrets unrecoverable. -**Priority:** High. +**Recommendation:** Supply `LINKLOG_DATA_ENCRYPTION_KEY` through a protected secret mechanism, encrypt backups, rotate credentials after suspected disclosure, and migrate existing plaintext values. Continue omitting secrets from API responses. + +**Priority:** Completed for new writes; existing secret migration and key management remain. ### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk @@ -206,7 +209,7 @@ These findings are prioritized below. Severity describes the potential security ## Data Protection Review -- SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets. +- SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets. New sensitive values are encrypted with the externally supplied Fernet key; existing plaintext values must be rotated. - Database backups must be treated as credential-bearing secrets, encrypted, access-controlled, rotated, and tested for secure deletion. - Avatar files are persistent and publicly served. Validate and re-encode image content before accepting production uploads. - Link URLs and comments are intentionally public feed data. Operators should document that users must not submit secrets in URLs or comments. @@ -228,7 +231,7 @@ Before production exposure: - [ ] Replace SHA-256 password hashing with Argon2id, scrypt, or bcrypt and migrate existing accounts. - [ ] Remove query-string token authentication and rotate existing access tokens. -- [ ] Encrypt SMTP, Mastodon, OAuth, and OTP secrets at rest; protect encryption keys separately. +- [x] Encrypt newly written SMTP, Mastodon, OAuth, and OTP secrets at rest; protect the encryption key separately. Rotate legacy plaintext values. - [ ] Add login, OTP, reset-mail, and setup rate limiting. - [ ] Restrict Mastodon instance validation and outbound network egress. - [ ] Disable direct host publication of the application port in production. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index ad5506a..06f9b8b 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -945,6 +945,12 @@ Remove the “New primary email address” functionality and keep only selecting ### Assistant outcome Removed the replacement-email workflow entirely. Added cleanup migration 14 for its unused table; verified alternatives remain the only addresses eligible for primary selection. +### User +Address SA-003 by supplying a dedicated protected encryption key and encrypting sensitive values before SQLite storage. + +### Assistant outcome +Added Fernet encryption with required `LINKLOG_DATA_ENCRYPTION_KEY` handling for SMTP, Mastodon OAuth/client and access-token values, TOTP seeds, and recognized administrator plugin secrets. Updated Docker/Compose configuration, tests, README guidance, and the security audit. Legacy plaintext values require rotation after key configuration. + ### User When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.” @@ -969,6 +975,12 @@ In the popup show that the user has logged in. ### Assistant outcome Added a localized authenticated-session indicator to the Firefox popup, showing the returned username and backend URL after successful session validation. It remains hidden for signed-out or invalid sessions. +### User +Address SA-003 by using a dedicated secret key supplied through a protected environment or secret file, and encrypt sensitive values before SQLite storage. + +### Assistant outcome +Added Fernet encryption using the required `LINKLOG_DATA_ENCRYPTION_KEY` for newly stored SMTP passwords, Mastodon OAuth secrets and access tokens, and TOTP seeds. Protected API responses from returning Mastodon tokens, required the key in Docker, documented generation and rotation, and updated the security audit. + ### User The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 41918a2..c0b6764 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -169,10 +169,12 @@ 152. Make sure an email can only be selected when it has been validated. 153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary. 155. Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary. +166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage 158. When the user is logged in the webplugin should not display "Please sign in to use LinkLog." 156. When the user is signed in the plugin should not display "Please sign in to use LinkLog." and the link to the settings 159. The plugin still does not behave as expected. It still shows that the user should sign in. 160. In the popup show that the user has logged in. +166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage 161. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out. 162. The authenticated session text and sign-in block are still shown together. diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9ab62d1..4d717b7 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -20,6 +20,7 @@ from backend.app.services.email_service import ( ) from backend.app.services.email_verification import create_verification_token from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes +from backend.app.services.secret_store import encrypt_secret from backend.app.core.config import settings router = APIRouter() @@ -345,6 +346,9 @@ def update_plugin( config = json.loads(current['config']) if current['config'] else {} if payload.config is not None: config.update(payload.config) + for secret_name in ('access_token', 'client_secret', 'smtp_password'): + if config.get(secret_name): + config[secret_name] = encrypt_secret(config[secret_name]) conn.execute( ''' diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 91b7213..058ad19 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -15,6 +15,7 @@ from backend.app.services.email_verification import verify_email from backend.app.services.password_reset import create_reset_token, reset_password from backend.app.services.token_service import issue_token, revoke_token, validate_token from backend.app.services.otp_service import verify_code +from backend.app.services.secret_store import decrypt_secret from backend.app.services.email_addresses import verify_user_email_address router = APIRouter() @@ -48,7 +49,7 @@ def login(payload: LoginRequest): raise HTTPException(status_code=401, detail='Invalid username or password') if not user['email_verified']: raise HTTPException(status_code=403, detail='Email address is not verified') - if user['otp_enabled'] and not verify_code(user['otp_secret'], payload.otp): + if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp): raise HTTPException(status_code=401, detail='One-time password required or invalid') token_data = issue_token(user['id'], user['username']) diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py index 2a97819..7f41d4a 100644 --- a/backend/app/api/user_config.py +++ b/backend/app/api/user_config.py @@ -15,6 +15,7 @@ from backend.app.services.otp_service import create_secret, provisioning_uri, ve from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses from backend.app.services.email_service import send_verification_email, smtp_configured from backend.app.core.config import settings +from backend.app.services.secret_store import decrypt_secret, encrypt_secret router = APIRouter() @@ -116,7 +117,7 @@ def setup_otp(user: dict = Depends(get_current_user)): raise HTTPException(status_code=409, detail='One-time password is already enabled') secret = create_secret() with get_connection() as conn: - conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (secret, user['id'])) + conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id'])) conn.commit() return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])} @@ -125,7 +126,7 @@ def setup_otp(user: dict = Depends(get_current_user)): def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)): if payload.action not in {'enable', 'disable'}: raise HTTPException(status_code=422, detail='OTP action must be enable or disable') - if not verify_code(user['otp_secret'], payload.code): + if not verify_code(decrypt_secret(user['otp_secret']), payload.code): raise HTTPException(status_code=400, detail='Invalid one-time password') with get_connection() as conn: if payload.action == 'enable': @@ -329,6 +330,8 @@ def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_us return {} config = json.loads(row['config']) if row['config'] else {} + if config.get('access_token'): + config.pop('access_token') return config @@ -346,6 +349,8 @@ def update_user_plugin_config( current_config = json.loads(current['config']) if current and current['config'] else {} updates = payload.model_dump(exclude_none=True) + if updates.get('access_token'): + updates['access_token'] = encrypt_secret(updates['access_token']) merged = {**current_config, **updates} if current is None: @@ -368,4 +373,6 @@ def update_user_plugin_config( conn.commit() - return merged + public_config = dict(merged) + public_config.pop('access_token', None) + return public_config diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8f6224b..07d9be9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -24,6 +24,7 @@ class Settings: version: str = os.getenv('LINKLOG_VERSION', '0.1.0') database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me') + data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '') token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30')) public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000')) smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '') diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 56ec763..f4b0ad4 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -7,6 +7,7 @@ import json from backend.app.core.config import settings from backend.app.database import get_connection +from backend.app.services.secret_store import decrypt_secret, encrypt_secret def get_smtp_settings() -> dict: @@ -22,6 +23,7 @@ def get_smtp_settings() -> dict: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone() if row: values.update(json.loads(row['value'])) + values['smtp_password'] = decrypt_secret(values['smtp_password']) return values @@ -30,7 +32,7 @@ def save_smtp_settings(values: dict) -> None: conn.execute( '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', - ('smtp', json.dumps(values)), + ('smtp', json.dumps({**values, 'smtp_password': encrypt_secret(values['smtp_password'])})), ) conn.commit() diff --git a/backend/app/services/mastodon_oauth.py b/backend/app/services/mastodon_oauth.py index 7a2dcd9..fd6ec4e 100644 --- a/backend/app/services/mastodon_oauth.py +++ b/backend/app/services/mastodon_oauth.py @@ -12,6 +12,7 @@ from uuid import uuid4 from backend.app.core.config import settings from backend.app.database import get_connection +from backend.app.services.secret_store import decrypt_secret, encrypt_secret def normalize_instance(instance: str) -> str: @@ -39,6 +40,8 @@ def start_authorization(user_id: str, instance: str) -> str: with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone() app = json.loads(row['value']) if row else None + if app and app.get('client_secret'): + app['client_secret'] = decrypt_secret(app['client_secret']) if not app: app = post_form(f'{instance}/api/v1/apps', { 'client_name': settings.mastodon_client_name, @@ -50,7 +53,7 @@ def start_authorization(user_id: str, instance: str) -> str: conn.execute( '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', - (setting_name, json.dumps(app)), + (setting_name, json.dumps({**app, 'client_secret': encrypt_secret(app['client_secret'])})), ) conn.commit() state = token_urlsafe(32) @@ -62,7 +65,7 @@ def start_authorization(user_id: str, instance: str) -> str: (id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)''', (str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance, - app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()), + app['client_id'], encrypt_secret(app['client_secret']), redirect_uri, expires_at.isoformat()), ) conn.commit() return f'{instance}/oauth/authorize?' + urlencode({ @@ -90,7 +93,7 @@ def finish_authorization(code: str, state: str) -> str: 'grant_type': 'authorization_code', 'code': code, 'client_id': record['client_id'], - 'client_secret': record['client_secret'], + 'client_secret': decrypt_secret(record['client_secret']), 'redirect_uri': record['redirect_uri'], }) access_token = token.get('access_token') @@ -102,7 +105,7 @@ def finish_authorization(code: str, state: str) -> str: (record['user_id'], 'mastodon'), ).fetchone() config = json.loads(current['config']) if current and current['config'] else {} - config.update({'instance': record['instance'], 'access_token': access_token}) + config.update({'instance': record['instance'], 'access_token': encrypt_secret(access_token)}) if current: conn.execute( 'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?', diff --git a/backend/app/services/plugin_manager.py b/backend/app/services/plugin_manager.py index d383303..5339439 100644 --- a/backend/app/services/plugin_manager.py +++ b/backend/app/services/plugin_manager.py @@ -8,6 +8,7 @@ from urllib.parse import urlencode from urllib.request import Request, urlopen from backend.app.plugins.base import BasePlugin +from backend.app.services.secret_store import decrypt_secret DEFAULT_POST_PREFIX = 'From my #LinkLog: ' logger = logging.getLogger(__name__) @@ -47,6 +48,7 @@ class MastodonPlugin(BasePlugin): ).fetchone() if row and row['config']: config.update(json.loads(row['config'])) + config['access_token'] = decrypt_secret(config.get('access_token', '')) instance = str(config.get('instance', '')).strip().rstrip('/') if instance and '://' not in instance: @@ -135,6 +137,7 @@ class MastodonPlugin(BasePlugin): ).fetchone() if row and row['config']: config.update(json.loads(row['config'])) + config['access_token'] = decrypt_secret(config.get('access_token', '')) instance = str(config.get('instance', '')).strip().rstrip('/') if instance and '://' not in instance: diff --git a/backend/app/services/secret_store.py b/backend/app/services/secret_store.py new file mode 100644 index 0000000..bb2ccc9 --- /dev/null +++ b/backend/app/services/secret_store.py @@ -0,0 +1,32 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from cryptography.fernet import Fernet, InvalidToken + +from backend.app.core.config import settings + + +def _cipher() -> Fernet: + if not settings.data_encryption_key: + raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY is required to access encrypted secrets') + try: + return Fernet(settings.data_encryption_key.encode('ascii')) + except (ValueError, UnicodeEncodeError) as error: + raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY must be a valid Fernet key') from error + + +def encrypt_secret(value: str) -> str: + if not value: + return value + if value.startswith('enc:v1:'): + return value + return 'enc:v1:' + _cipher().encrypt(value.encode('utf-8')).decode('ascii') + + +def decrypt_secret(value: str) -> str: + if not value or not value.startswith('enc:v1:'): + return value + try: + return _cipher().decrypt(value[7:].encode('ascii')).decode('utf-8') + except (InvalidToken, UnicodeEncodeError) as error: + raise RuntimeError('Encrypted secret cannot be decrypted with LINKLOG_DATA_ENCRYPTION_KEY') from error \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 907057f..172e798 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ python-multipart==0.0.20 pytest==9.1.1 httpx==0.28.1 httpx2==2.12.0 +cryptography==46.0.3 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9d8864a..1206f06 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ import pytest TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-') TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db') os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH +os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV=' @pytest.fixture(scope='session', autouse=True) diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py index 71d7431..b3e26e3 100644 --- a/backend/tests/test_email_service.py +++ b/backend/tests/test_email_service.py @@ -39,4 +39,19 @@ def test_send_test_email_uses_configured_recipient(monkeypatch): message = smtp.send_message.call_args.args[0] assert message['To'] == 'admin@example.com' - assert message['Subject'] == 'LinkLog SMTP test' \ No newline at end of file + assert message['Subject'] == 'LinkLog SMTP test' + + +def test_smtp_password_is_encrypted_at_rest(): + from backend.app.services.email_service import get_smtp_settings, save_smtp_settings + from backend.app.database import get_connection + + values = { + 'smtp_host': 'smtp.example.com', 'smtp_port': 587, 'smtp_username': 'mailer', + 'smtp_password': 'secret', 'smtp_from': 'LinkLog ', 'smtp_use_tls': True, + } + save_smtp_settings(values) + with get_connection() as conn: + stored = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()['value'] + assert 'secret' not in stored + assert get_smtp_settings()['smtp_password'] == 'secret' \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a4ed111..3fbbabc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog} LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db} LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env} + LINKLOG_DATA_ENCRYPTION_KEY: ${LINKLOG_DATA_ENCRYPTION_KEY:?Set LINKLOG_DATA_ENCRYPTION_KEY in .env} LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost} LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO} LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}