diff --git a/backend/alembic/versions/0107_link_token_at_rest.py b/backend/alembic/versions/0107_link_token_at_rest.py new file mode 100644 index 00000000..30554da0 --- /dev/null +++ b/backend/alembic/versions/0107_link_token_at_rest.py @@ -0,0 +1,66 @@ +"""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") diff --git a/backend/app/audit.py b/backend/app/audit.py index 6a5f5170..d44606ba 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -73,6 +73,11 @@ class AuditAction: # What a token then did is the file rows above, their summary naming it. API_TOKEN_CREATE = "api_token_create" API_TOKEN_REVOKE = "api_token_revoke" + # A public `/watch` link to a file (`routes.downloads`): made, its settings changed, revoked. + # Never its token or password — only whether it has one. + DOWNLOAD_LINK_CREATE = "download_link_create" + DOWNLOAD_LINK_UPDATE = "download_link_update" + DOWNLOAD_LINK_REVOKE = "download_link_revoke" def record( diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 0f023774..f4a2c3cf 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -1,10 +1,14 @@ """Public "share by link" helpers for the download center. -A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one -download's file on the login-free `/watch/{token}` page. Password-protected links additionally -require a short-lived signed **grant** on the file request — because a `