From 8d62ac001735bd95846b76714e64418efd7f03dc Mon Sep 17 00:00:00 2001 From: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:13:30 +0000 Subject: [PATCH] security: encrypt OAuth tokens at rest (Fernet) + docs --- modern/backend/README_ENCRYPTION.md | 15 ++++++++ modern/backend/app.py | 6 ++- modern/backend/crypto.py | 56 ++++++++++++++++++++++++++++ modern/backend/oauth.py | 24 ++++++------ modern/backend/requirements_full.txt | 1 + 5 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 modern/backend/README_ENCRYPTION.md create mode 100644 modern/backend/crypto.py diff --git a/modern/backend/README_ENCRYPTION.md b/modern/backend/README_ENCRYPTION.md new file mode 100644 index 0000000..a7cf36a --- /dev/null +++ b/modern/backend/README_ENCRYPTION.md @@ -0,0 +1,15 @@ +Token encryption notes + +This project includes a small helper (`crypto.py`) that uses Fernet (symmetric AES-GCM via cryptography) to encrypt OAuth tokens at rest. + +Development behavior +- If `LIFERPG_DATA_KEY` env var is not set, the helper will create a key in `modern/backend/.dev_liferpg_key` with restrictive permissions (0600). This is intended for local development only. + +Production guidance +- Provide a stable, secure encryption key via environment variable `LIFERPG_DATA_KEY` from a secrets manager (Vault, AWS KMS, GCP KMS, etc). +- Rotate keys using envelope encryption: encrypt tokens with a data key and wrap the data key with KMS. +- Consider using a separate secrets store (HashiCorp Vault) and avoid storing ciphertext in the primary DB if required. + +Notes +- If the key changes, stored tokens cannot be decrypted. Provide migration/rotation paths through KMS envelope encryption. +- This helper is intentionally small and pragmatic — replace with a hardened secrets management path in production. diff --git a/modern/backend/app.py b/modern/backend/app.py index be7d33f..8908209 100644 --- a/modern/backend/app.py +++ b/modern/backend/app.py @@ -57,7 +57,11 @@ def google_events(integration_id: int): if not token or not token.access_token: raise HTTPException(status_code=404, detail='no token found for integration') - headers = {'Authorization': f'Bearer {token.access_token}'} + from .crypto import decrypt_text + decrypted_access = decrypt_text(token.access_token) + if not decrypted_access: + raise HTTPException(status_code=500, detail='unable to decrypt access token') + headers = {'Authorization': f'Bearer {decrypted_access}'} params = {'maxResults': 10, 'singleEvents': True, 'orderBy': 'startTime', 'timeMin': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} resp = requests.get('https://www.googleapis.com/calendar/v3/calendars/primary/events', headers=headers, params=params, timeout=10) if resp.status_code != 200: diff --git a/modern/backend/crypto.py b/modern/backend/crypto.py new file mode 100644 index 0000000..1cc27e6 --- /dev/null +++ b/modern/backend/crypto.py @@ -0,0 +1,56 @@ +import os +from cryptography.fernet import Fernet, InvalidToken + +KEY_ENV = 'LIFERPG_DATA_KEY' +FALLBACK_KEY_PATH = os.path.join(os.path.dirname(__file__), '.dev_liferpg_key') + + +def _load_key_from_env(): + v = os.getenv(KEY_ENV) + if v: + return v.encode() + return None + + +def _load_or_create_fallback_key(): + # Try to read an existing key file (dev convenience). Create with restrictive perms if missing. + try: + if os.path.exists(FALLBACK_KEY_PATH): + with open(FALLBACK_KEY_PATH, 'rb') as f: + return f.read().strip() + # generate and persist locally (dev only) + key = Fernet.generate_key() + # write file with 0600 perms + fd = os.open(FALLBACK_KEY_PATH, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as f: + f.write(key) + return key + except Exception: + return None + + +def get_fernet(): + key = _load_key_from_env() or _load_or_create_fallback_key() + if not key: + raise RuntimeError('Encryption key not available. Set env var LIFERPG_DATA_KEY or allow creating a dev key file.') + return Fernet(key) + + +def encrypt_text(plaintext: str) -> str: + if plaintext is None: + return '' + f = get_fernet() + token = f.encrypt(plaintext.encode('utf-8')) + return token.decode('utf-8') + + +def decrypt_text(token_text: str) -> str: + if not token_text: + return '' + f = get_fernet() + try: + out = f.decrypt(token_text.encode('utf-8')) + return out.decode('utf-8') + except InvalidToken: + # Token can't be decrypted — likely different key; surface empty + return '' diff --git a/modern/backend/oauth.py b/modern/backend/oauth.py index 3890741..2ec96d5 100644 --- a/modern/backend/oauth.py +++ b/modern/backend/oauth.py @@ -71,18 +71,20 @@ async def google_callback(request: Request): db.commit() db.refresh(integration) - # Persist token (single latest token demo) - expires_at = None - if token.get('expires_in'): - expires_at = int(time.time()) + int(token.get('expires_in')) + # Persist token (single latest token demo). Encrypt tokens at rest. + from .crypto import encrypt_text - oauth_token = models.OAuthToken( - integration_id=integration.id, - access_token=token.get('access_token'), - refresh_token=token.get('refresh_token'), - scope=token.get('scope'), - expires_at=expires_at - ) + expires_at = None + if token.get('expires_in'): + expires_at = int(time.time()) + int(token.get('expires_in')) + + oauth_token = models.OAuthToken( + integration_id=integration.id, + access_token=encrypt_text(token.get('access_token') or ''), + refresh_token=encrypt_text(token.get('refresh_token') or ''), + scope=token.get('scope'), + expires_at=expires_at + ) db.add(oauth_token) db.commit() diff --git a/modern/backend/requirements_full.txt b/modern/backend/requirements_full.txt index 9586333..286a75e 100644 --- a/modern/backend/requirements_full.txt +++ b/modern/backend/requirements_full.txt @@ -4,3 +4,4 @@ sqlalchemy authlib python-dotenv requests +cryptography