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
This commit is contained in:
co-authored by
Claude Opus 5
parent
11d491dae0
commit
ca7a16c8bc
@@ -0,0 +1,42 @@
|
||||
"""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])
|
||||
Reference in New Issue
Block a user