Files
app/backend/scripts/rotate_secret_key.py
T
Melvin StroblandClaude Opus 5 ca7a16c8bc Guard webhooks with a per-hook secret, and allow rotating the app key
Trigger hooks are mounted unauthenticated because devices cannot present
a JWT. They now take a secret as a trailing path segment, so a device
needs one URL and no header support. The value never enters the
registered route, only a {secret} template, and is compared with
compare_digest; a mismatch is a bare 404 so the endpoint does not
confirm which hooks exist. Pointing the parameter at the encrypted store
keeps the literal out of flow.json. Hooks without a secret keep working
and now raise a validation issue saying so.

Rotating SECRET_KEY made the stored secrets unreadable for good, since
the Fernet key derives from it. scripts/rotate_secret_key.py re-encrypts
with the new key and refuses if the old one does not decrypt. Now that
recovery exists, an unreadable store fails loudly instead of coming back
empty and leaving flows short of credentials with no visible cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
2026-08-15 21:19:32 +02:00

43 lines
1.3 KiB
Python

"""Re-encrypt the secrets store after SECRET_KEY was rotated.
The store's encryption key is derived from SECRET_KEY, so a new key leaves
``secrets.enc`` unreadable. Put the new key in `.env` first, then hand this
script the previous one:
uv run python scripts/rotate_secret_key.py <old-secret-key>
"""
import logging
import sys
from app.core.config import settings
from app.flow.secrets import SecretsStore, SecretsUnreadable
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def main(old_key: str) -> None:
path = settings.SECRETS_FILE
if not path.exists():
sys.exit(f"No secrets store at {path} — nothing to re-encrypt.")
if old_key == settings.SECRET_KEY:
sys.exit("Old and new key are identical — set the new SECRET_KEY first.")
# The store has no bulk export; reading and writing it whole is the point.
try:
data = SecretsStore(path, old_key)._read()
except SecretsUnreadable:
sys.exit(f"{path} does not decrypt with that key — refusing to overwrite it.")
SecretsStore(path, settings.SECRET_KEY)._write(data)
logger.info(
"Re-encrypted %d secret(s) in %s with the current SECRET_KEY", len(data), path
)
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: rotate_secret_key.py <old-secret-key>")
main(sys.argv[1])