A /watch link's token is the credential, and it sat in the database as written. Migration 0107 replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner can copy the same link again); every existing address keeps working, and the downgrade decrypts them back. Without a copy this key can read, a link still opens but its address cannot be shown: "Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says so in that row instead of a broken URL. Making, changing and revoking a link are audited, never with its token or password.
67 lines
2.8 KiB
Python
67 lines
2.8 KiB
Python
"""Share-link tokens no longer stored in the clear
|
|
|
|
Revision ID: 0107_link_token_at_rest
|
|
Revises: 0106_api_tokens
|
|
Create Date: 2026-10-15
|
|
|
|
A `/watch` link's token is the credential itself, and it sat in `download_links.token` as written,
|
|
so a copy of the database was every shared file. Now a link is found by the token's SHA-256
|
|
(`token_hash`), and the token is kept only Fernet-encrypted (`token_enc`, TOKEN_ENCRYPTION_KEY) so its
|
|
owner can copy the same link again. Without a key the copy is not kept: the link still opens, its
|
|
address just cannot be shown again (`app/downloads/links.py`).
|
|
|
|
Every existing link is converted here, so the addresses already sent keep working. The downgrade
|
|
writes the tokens back from their encrypted copies; a link with none left (no key, or a key since
|
|
changed) gets a fresh random token, which is the same as revoking it.
|
|
"""
|
|
|
|
import hashlib
|
|
import secrets
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
from app import security
|
|
|
|
revision: str = "0107_link_token_at_rest"
|
|
down_revision: str | None = "0106_api_tokens"
|
|
branch_labels: str | None = None
|
|
depends_on: str | None = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.add_column("download_links", sa.Column("token_hash", sa.String(64)))
|
|
op.add_column("download_links", sa.Column("token_enc", sa.Text()))
|
|
conn = op.get_bind()
|
|
sealing = security.can_encrypt()
|
|
for link_id, token in conn.execute(sa.text("SELECT id, token FROM download_links")).fetchall():
|
|
conn.execute(
|
|
sa.text("UPDATE download_links SET token_hash = :h, token_enc = :e WHERE id = :id"),
|
|
{
|
|
"id": link_id,
|
|
"h": hashlib.sha256(token.encode()).hexdigest(),
|
|
"e": security.encrypt(token) if sealing else None,
|
|
},
|
|
)
|
|
op.alter_column("download_links", "token_hash", nullable=False)
|
|
op.create_unique_constraint("uq_download_link_token_hash", "download_links", ["token_hash"])
|
|
op.drop_constraint("uq_download_link_token", "download_links", type_="unique")
|
|
op.drop_column("download_links", "token")
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.add_column("download_links", sa.Column("token", sa.String(64)))
|
|
conn = op.get_bind()
|
|
rows = conn.execute(sa.text("SELECT id, token_enc FROM download_links")).fetchall()
|
|
for link_id, enc in rows:
|
|
token = security.decrypt_optional(enc) or secrets.token_urlsafe(24)
|
|
conn.execute(
|
|
sa.text("UPDATE download_links SET token = :t WHERE id = :id"),
|
|
{"id": link_id, "t": token},
|
|
)
|
|
op.alter_column("download_links", "token", nullable=False)
|
|
op.create_unique_constraint("uq_download_link_token", "download_links", ["token"])
|
|
op.drop_constraint("uq_download_link_token_hash", "download_links", type_="unique")
|
|
op.drop_column("download_links", "token_enc")
|
|
op.drop_column("download_links", "token_hash")
|