"""Seal forge-connection tokens at rest Revision ID: 0122 Revises: 0121 Create Date: 2026-10-15 A forge token was stored as plain text, so a copy of the database (or of a backup tool's snapshot of it) was enough to read every user's forge credential. Tokens are now sealed the way the backup destination's S3 secret is (services/secrets.py): Fernet under a key derived from SECRET_KEY, which lives in the deployment's environment rather than in the data. The column is renamed `token` -> `token_sealed` in the same step. The name says what the bytes are, and every reader has to change with it, so a reader missed here fails on a missing attribute instead of quietly sending ciphertext to the forge as a token. THE KEY DERIVATION IS FROZEN HERE, not imported. This revision must seal with exactly v1's key forever; importing services.secrets would let a later edit there change what an old migration writes. The unit lane checks that what this produces is what `secrets.unseal` opens today. Cost: one row per (user, forge host) — a handful per instance. Downgrade opens each token back into plain text. A row sealed under a different SECRET_KEY cannot be opened and comes back empty; its owner enters the token again. """ import base64 import hashlib import sqlalchemy as sa from alembic import op revision = "0122" down_revision = "0121" branch_labels = None depends_on = None _LABEL_V1 = b"scribe.secrets.v1\x00" def _fernet(): from cryptography.fernet import Fernet from grimoire.config import Config digest = hashlib.sha256(_LABEL_V1 + Config.SECRET_KEY.encode()).digest() return Fernet(base64.urlsafe_b64encode(digest)) def seal_v1(plain: str) -> str: return _fernet().encrypt(plain.encode()).decode() def upgrade() -> None: op.alter_column("forge_connections", "token", new_column_name="token_sealed") bind = op.get_bind() rows = bind.execute(sa.text("SELECT id, token_sealed FROM forge_connections")).all() for row_id, plain in rows: bind.execute( sa.text("UPDATE forge_connections SET token_sealed = :v WHERE id = :id"), {"v": seal_v1(plain or ""), "id": row_id}, ) def downgrade() -> None: from cryptography.fernet import InvalidToken bind = op.get_bind() fernet = _fernet() rows = bind.execute(sa.text("SELECT id, token_sealed FROM forge_connections")).all() for row_id, sealed in rows: try: plain = fernet.decrypt((sealed or "").encode()).decode() except (InvalidToken, ValueError): plain = "" bind.execute( sa.text("UPDATE forge_connections SET token_sealed = :v WHERE id = :id"), {"v": plain, "id": row_id}, ) op.alter_column("forge_connections", "token_sealed", new_column_name="token")