CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 36s
The column becomes token_sealed (migration 0122 seals existing rows with a frozen copy of the v1 key), every reader opens it through connection_token, a row this instance cannot open is skipped with a warning, and Settings asks for that token again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
"""Seal forge-connection tokens at rest (milestone 523)
|
|
|
|
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 scribe.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")
|