open-swe/agent/encryption.py
open-swe[bot] 148efeb269
feat: support TOKEN_ENCRYPTION_KEY rotation via MultiFernet [closes AB-2323] (#1275)
Threat model T6: Fernet token-encryption key had no rotation path. Rotating
TOKEN_ENCRYPTION_KEY immediately invalidated every github_token_encrypted
value in thread metadata, forcing re-auth or bot-token re-resolution.

Switch to cryptography.fernet.MultiFernet and parse TOKEN_ENCRYPTION_KEY as a
comma- or newline-separated ordered list (most-recent-first). New writes
encrypt under the first key; reads try every key. Deployers can prepend a new
key, let active threads roll over, then drop the old key.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
2026-05-08 14:29:23 -07:00

71 lines
2.1 KiB
Python

"""Encryption utilities for sensitive data like tokens."""
import logging
import os
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
logger = logging.getLogger(__name__)
class EncryptionKeyMissingError(ValueError):
"""Raised when TOKEN_ENCRYPTION_KEY environment variable is not set."""
def _parse_encryption_keys(raw: str) -> list[bytes]:
"""Split TOKEN_ENCRYPTION_KEY into one or more keys (most-recent-first)."""
keys: list[bytes] = []
for part in raw.replace("\n", ",").split(","):
stripped = part.strip()
if stripped:
keys.append(stripped.encode())
return keys
def _get_encryption_keys() -> list[bytes]:
"""Read the ordered key list from TOKEN_ENCRYPTION_KEY (most-recent-first).
Accepts a single key or a comma/newline separated list. The first key is
used for encryption; every key is tried for decryption.
Raises:
EncryptionKeyMissingError: If TOKEN_ENCRYPTION_KEY is unset or empty.
"""
explicit_key = os.environ.get("TOKEN_ENCRYPTION_KEY")
if not explicit_key:
raise EncryptionKeyMissingError
keys = _parse_encryption_keys(explicit_key)
if not keys:
raise EncryptionKeyMissingError
return keys
def _get_fernet() -> MultiFernet:
"""Build a MultiFernet from the configured key list."""
return MultiFernet([Fernet(k) for k in _get_encryption_keys()])
def encrypt_token(token: str) -> str:
"""Encrypt a token under the newest configured key."""
if not token:
return ""
encrypted = _get_fernet().encrypt(token.encode())
return encrypted.decode()
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt a token, trying each configured key in order."""
if not encrypted_token:
return ""
try:
decrypted = _get_fernet().decrypt(encrypted_token.encode())
return decrypted.decode()
except InvalidToken:
logger.warning("Failed to decrypt token: invalid token")
return ""
except EncryptionKeyMissingError:
logger.warning("Failed to decrypt token: encryption key not set")
return ""