Addressed SA-3 by encrypting the sqlite content with a .env secret
This commit is contained in:
@@ -10,6 +10,7 @@ APP_HEALTHCHECK_RETRIES=3
|
|||||||
LINKLOG_APP_NAME=LinkLog
|
LINKLOG_APP_NAME=LinkLog
|
||||||
LINKLOG_VERSION=0.1.0
|
LINKLOG_VERSION=0.1.0
|
||||||
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
|
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_TOKEN_EXPIRY_DAYS=30
|
||||||
LINKLOG_PUBLIC_URL=localhost
|
LINKLOG_PUBLIC_URL=localhost
|
||||||
LINKLOG_SMTP_HOST=
|
LINKLOG_SMTP_HOST=
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ The main configurable values are:
|
|||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker |
|
| `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_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` |
|
||||||
| `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` |
|
| `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` |
|
| `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.
|
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:
|
Build and start the application:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+9
-6
@@ -65,13 +65,16 @@ These findings are prioritized below. Severity describes the potential security
|
|||||||
|
|
||||||
### SA-003: Sensitive secrets stored in plaintext SQLite
|
### SA-003: Sensitive secrets stored in plaintext SQLite
|
||||||
|
|
||||||
**Severity:** High
|
**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.
|
**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.
|
**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
|
### 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- [ ] Replace SHA-256 password hashing with Argon2id, scrypt, or bcrypt and migrate existing accounts.
|
||||||
- [ ] Remove query-string token authentication and rotate existing access tokens.
|
- [ ] 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.
|
- [ ] Add login, OTP, reset-mail, and setup rate limiting.
|
||||||
- [ ] Restrict Mastodon instance validation and outbound network egress.
|
- [ ] Restrict Mastodon instance validation and outbound network egress.
|
||||||
- [ ] Disable direct host publication of the application port in production.
|
- [ ] Disable direct host publication of the application port in production.
|
||||||
|
|||||||
@@ -945,6 +945,12 @@ Remove the “New primary email address” functionality and keep only selecting
|
|||||||
### Assistant outcome
|
### 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.
|
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
|
### User
|
||||||
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
|
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
|
### 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.
|
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
|
### User
|
||||||
The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
|
The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
|
||||||
|
|
||||||
|
|||||||
@@ -169,10 +169,12 @@
|
|||||||
152. Make sure an email can only be selected when it has been validated.
|
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.
|
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.
|
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."
|
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
|
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.
|
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.
|
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.
|
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.
|
162. The authenticated session text and sign-in block are still shown together.
|
||||||
|
|
||||||
|
|||||||
@@ -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.email_verification import create_verification_token
|
||||||
from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes
|
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
|
from backend.app.core.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -345,6 +346,9 @@ def update_plugin(
|
|||||||
config = json.loads(current['config']) if current['config'] else {}
|
config = json.loads(current['config']) if current['config'] else {}
|
||||||
if payload.config is not None:
|
if payload.config is not None:
|
||||||
config.update(payload.config)
|
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(
|
conn.execute(
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -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.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.token_service import issue_token, revoke_token, validate_token
|
||||||
from backend.app.services.otp_service import verify_code
|
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
|
from backend.app.services.email_addresses import verify_user_email_address
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -48,7 +49,7 @@ def login(payload: LoginRequest):
|
|||||||
raise HTTPException(status_code=401, detail='Invalid username or password')
|
raise HTTPException(status_code=401, detail='Invalid username or password')
|
||||||
if not user['email_verified']:
|
if not user['email_verified']:
|
||||||
raise HTTPException(status_code=403, detail='Email address is not 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')
|
raise HTTPException(status_code=401, detail='One-time password required or invalid')
|
||||||
|
|
||||||
token_data = issue_token(user['id'], user['username'])
|
token_data = issue_token(user['id'], user['username'])
|
||||||
|
|||||||
@@ -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_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.services.email_service import send_verification_email, smtp_configured
|
||||||
from backend.app.core.config import settings
|
from backend.app.core.config import settings
|
||||||
|
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
router = APIRouter()
|
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')
|
raise HTTPException(status_code=409, detail='One-time password is already enabled')
|
||||||
secret = create_secret()
|
secret = create_secret()
|
||||||
with get_connection() as conn:
|
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()
|
conn.commit()
|
||||||
return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])}
|
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)):
|
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||||
if payload.action not in {'enable', 'disable'}:
|
if payload.action not in {'enable', 'disable'}:
|
||||||
raise HTTPException(status_code=422, detail='OTP action must be enable or 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')
|
raise HTTPException(status_code=400, detail='Invalid one-time password')
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
if payload.action == 'enable':
|
if payload.action == 'enable':
|
||||||
@@ -329,6 +330,8 @@ def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_us
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
config = json.loads(row['config']) if row['config'] else {}
|
config = json.loads(row['config']) if row['config'] else {}
|
||||||
|
if config.get('access_token'):
|
||||||
|
config.pop('access_token')
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -346,6 +349,8 @@ def update_user_plugin_config(
|
|||||||
|
|
||||||
current_config = json.loads(current['config']) if current and current['config'] else {}
|
current_config = json.loads(current['config']) if current and current['config'] else {}
|
||||||
updates = payload.model_dump(exclude_none=True)
|
updates = payload.model_dump(exclude_none=True)
|
||||||
|
if updates.get('access_token'):
|
||||||
|
updates['access_token'] = encrypt_secret(updates['access_token'])
|
||||||
merged = {**current_config, **updates}
|
merged = {**current_config, **updates}
|
||||||
|
|
||||||
if current is None:
|
if current is None:
|
||||||
@@ -368,4 +373,6 @@ def update_user_plugin_config(
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return merged
|
public_config = dict(merged)
|
||||||
|
public_config.pop('access_token', None)
|
||||||
|
return public_config
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Settings:
|
|||||||
version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
|
version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
|
||||||
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
|
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')
|
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'))
|
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'))
|
public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000'))
|
||||||
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
|
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
|
|
||||||
from backend.app.core.config import settings
|
from backend.app.core.config import settings
|
||||||
from backend.app.database import get_connection
|
from backend.app.database import get_connection
|
||||||
|
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
def get_smtp_settings() -> dict:
|
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()
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()
|
||||||
if row:
|
if row:
|
||||||
values.update(json.loads(row['value']))
|
values.update(json.loads(row['value']))
|
||||||
|
values['smtp_password'] = decrypt_secret(values['smtp_password'])
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ def save_smtp_settings(values: dict) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = 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()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from backend.app.core.config import settings
|
from backend.app.core.config import settings
|
||||||
from backend.app.database import get_connection
|
from backend.app.database import get_connection
|
||||||
|
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
def normalize_instance(instance: str) -> str:
|
def normalize_instance(instance: str) -> str:
|
||||||
@@ -39,6 +40,8 @@ def start_authorization(user_id: str, instance: str) -> str:
|
|||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
||||||
app = json.loads(row['value']) if row else None
|
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:
|
if not app:
|
||||||
app = post_form(f'{instance}/api/v1/apps', {
|
app = post_form(f'{instance}/api/v1/apps', {
|
||||||
'client_name': settings.mastodon_client_name,
|
'client_name': settings.mastodon_client_name,
|
||||||
@@ -50,7 +53,7 @@ def start_authorization(user_id: str, instance: str) -> str:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = 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()
|
conn.commit()
|
||||||
state = token_urlsafe(32)
|
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)
|
(id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||||
(str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance,
|
(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()
|
conn.commit()
|
||||||
return f'{instance}/oauth/authorize?' + urlencode({
|
return f'{instance}/oauth/authorize?' + urlencode({
|
||||||
@@ -90,7 +93,7 @@ def finish_authorization(code: str, state: str) -> str:
|
|||||||
'grant_type': 'authorization_code',
|
'grant_type': 'authorization_code',
|
||||||
'code': code,
|
'code': code,
|
||||||
'client_id': record['client_id'],
|
'client_id': record['client_id'],
|
||||||
'client_secret': record['client_secret'],
|
'client_secret': decrypt_secret(record['client_secret']),
|
||||||
'redirect_uri': record['redirect_uri'],
|
'redirect_uri': record['redirect_uri'],
|
||||||
})
|
})
|
||||||
access_token = token.get('access_token')
|
access_token = token.get('access_token')
|
||||||
@@ -102,7 +105,7 @@ def finish_authorization(code: str, state: str) -> str:
|
|||||||
(record['user_id'], 'mastodon'),
|
(record['user_id'], 'mastodon'),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
config = json.loads(current['config']) if current and current['config'] else {}
|
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:
|
if current:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
|
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from urllib.parse import urlencode
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from backend.app.plugins.base import BasePlugin
|
from backend.app.plugins.base import BasePlugin
|
||||||
|
from backend.app.services.secret_store import decrypt_secret
|
||||||
|
|
||||||
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
|
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,6 +48,7 @@ class MastodonPlugin(BasePlugin):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row['config']:
|
if row and row['config']:
|
||||||
config.update(json.loads(row['config']))
|
config.update(json.loads(row['config']))
|
||||||
|
config['access_token'] = decrypt_secret(config.get('access_token', ''))
|
||||||
|
|
||||||
instance = str(config.get('instance', '')).strip().rstrip('/')
|
instance = str(config.get('instance', '')).strip().rstrip('/')
|
||||||
if instance and '://' not in instance:
|
if instance and '://' not in instance:
|
||||||
@@ -135,6 +137,7 @@ class MastodonPlugin(BasePlugin):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row and row['config']:
|
if row and row['config']:
|
||||||
config.update(json.loads(row['config']))
|
config.update(json.loads(row['config']))
|
||||||
|
config['access_token'] = decrypt_secret(config.get('access_token', ''))
|
||||||
|
|
||||||
instance = str(config.get('instance', '')).strip().rstrip('/')
|
instance = str(config.get('instance', '')).strip().rstrip('/')
|
||||||
if instance and '://' not in instance:
|
if instance and '://' not in instance:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,3 +6,4 @@ python-multipart==0.0.20
|
|||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
httpx2==2.12.0
|
httpx2==2.12.0
|
||||||
|
cryptography==46.0.3
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import pytest
|
|||||||
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
|
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
|
||||||
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
|
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
|
||||||
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
|
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
|
||||||
|
os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV='
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session', autouse=True)
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
|
|||||||
@@ -39,4 +39,19 @@ def test_send_test_email_uses_configured_recipient(monkeypatch):
|
|||||||
|
|
||||||
message = smtp.send_message.call_args.args[0]
|
message = smtp.send_message.call_args.args[0]
|
||||||
assert message['To'] == 'admin@example.com'
|
assert message['To'] == 'admin@example.com'
|
||||||
assert message['Subject'] == 'LinkLog SMTP test'
|
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 <no-reply@example.com>', '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'
|
||||||
@@ -16,6 +16,7 @@ services:
|
|||||||
LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog}
|
LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog}
|
||||||
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
|
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
|
||||||
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
|
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_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost}
|
||||||
LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO}
|
LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO}
|
||||||
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
||||||
|
|||||||
Reference in New Issue
Block a user