security: encrypt OAuth tokens at rest (Fernet) + docs
This commit is contained in:
parent
08a9c77b65
commit
8d62ac0017
15
modern/backend/README_ENCRYPTION.md
Normal file
15
modern/backend/README_ENCRYPTION.md
Normal 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.
|
||||||
|
|
@ -57,7 +57,11 @@ def google_events(integration_id: int):
|
||||||
if not token or not token.access_token:
|
if not token or not token.access_token:
|
||||||
raise HTTPException(status_code=404, detail='no token found for integration')
|
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())}
|
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)
|
resp = requests.get('https://www.googleapis.com/calendar/v3/calendars/primary/events', headers=headers, params=params, timeout=10)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
|
|
|
||||||
56
modern/backend/crypto.py
Normal file
56
modern/backend/crypto.py
Normal 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 ''
|
||||||
|
|
@ -71,15 +71,17 @@ async def google_callback(request: Request):
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(integration)
|
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
|
expires_at = None
|
||||||
if token.get('expires_in'):
|
if token.get('expires_in'):
|
||||||
expires_at = int(time.time()) + int(token.get('expires_in'))
|
expires_at = int(time.time()) + int(token.get('expires_in'))
|
||||||
|
|
||||||
oauth_token = models.OAuthToken(
|
oauth_token = models.OAuthToken(
|
||||||
integration_id=integration.id,
|
integration_id=integration.id,
|
||||||
access_token=token.get('access_token'),
|
access_token=encrypt_text(token.get('access_token') or ''),
|
||||||
refresh_token=token.get('refresh_token'),
|
refresh_token=encrypt_text(token.get('refresh_token') or ''),
|
||||||
scope=token.get('scope'),
|
scope=token.get('scope'),
|
||||||
expires_at=expires_at
|
expires_at=expires_at
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ sqlalchemy
|
||||||
authlib
|
authlib
|
||||||
python-dotenv
|
python-dotenv
|
||||||
requests
|
requests
|
||||||
|
cryptography
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user