security: encrypt OAuth tokens at rest (Fernet) + docs

This commit is contained in:
TLimoges33 2025-08-28 17:13:30 +00:00
parent 08a9c77b65
commit 8d62ac0017
5 changed files with 90 additions and 12 deletions

View File

@ -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.

View File

@ -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:

56
modern/backend/crypto.py Normal file
View File

@ -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 ''

View File

@ -71,15 +71,17 @@ async def google_callback(request: Request):
db.commit()
db.refresh(integration)
# Persist token (single latest token demo)
# Persist token (single latest token demo). Encrypt tokens at rest.
from .crypto import encrypt_text
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=token.get('access_token'),
refresh_token=token.get('refresh_token'),
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
)

View File

@ -4,3 +4,4 @@ sqlalchemy
authlib
python-dotenv
requests
cryptography