From 90567718b2fa32ed0d20608b27b9f74264345b86 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 01:02:29 +0200 Subject: [PATCH] feat(files): share-link tokens are kept only hashed and encrypted, and every link change is audited 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. --- .../versions/0107_link_token_at_rest.py | 66 +++++++ backend/app/audit.py | 5 + backend/app/downloads/links.py | 46 ++++- backend/app/downloads/og.py | 5 +- backend/app/models.py | 9 +- backend/app/routes/downloads.py | 46 ++++- backend/app/routes/public.py | 3 +- backend/app/security.py | 6 + backend/tests/test_link_token_at_rest.py | 187 ++++++++++++++++++ .../src/components/ShareDialog.dom.test.tsx | 81 +++++--- frontend/src/components/ShareDialog.tsx | 22 ++- .../src/i18n/locales/en/auditActions.json | 5 +- frontend/src/i18n/locales/en/downloads.json | 1 + .../src/i18n/locales/hu/auditActions.json | 5 +- frontend/src/i18n/locales/hu/downloads.json | 1 + frontend/src/lib/api/downloads.ts | 8 +- frontend/src/lib/downloads.test.ts | 29 +++ frontend/src/lib/downloads.ts | 15 +- 18 files changed, 480 insertions(+), 60 deletions(-) create mode 100644 backend/alembic/versions/0107_link_token_at_rest.py create mode 100644 backend/tests/test_link_token_at_rest.py 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 `