1from __future__ import annotations
2
3import base64
4import hashlib
5import hmac
6import os
7import time
8
9from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10
11_NONCE_BYTES = 12
12
13# Domain separator — the verifier (a Cloudflare Worker, not Python) must
14# prepend the exact same tag before HMAC. Lets the same secret sign other
15# message kinds later without confusion. If you change this string, update
16# the Worker side in lockstep.
17_RENDER_TOKEN_TAG = "render-token:"
18
19
20def _derive_key(secret: str) -> bytes:
21 # Plain SHA-256 (no salt/HKDF) because `secret` is a high-entropy
22 # server-generated value, not a user password.
23 return hashlib.sha256(secret.encode()).digest()
24
25
26def encrypt_identity(user_id: int | str, secret: str) -> str:
27 """AES-256-GCM-encrypt a user id. Returns base64url(nonce + ciphertext + tag)."""
28 key = _derive_key(secret)
29 nonce = os.urandom(_NONCE_BYTES)
30 ciphertext = AESGCM(key).encrypt(nonce, str(user_id).encode(), None)
31 return base64.urlsafe_b64encode(nonce + ciphertext).decode()
32
33
34def sign_render_token(secret: str, *, now: int | None = None) -> str:
35 """Sign a fresh render timestamp. Returns `<unix_seconds>.<hex_hmac_sha256>`, or `""` when no secret."""
36 if not secret:
37 return ""
38 ts = int(now if now is not None else time.time())
39 message = (_RENDER_TOKEN_TAG + str(ts)).encode()
40 mac = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
41 return f"{ts}.{mac}"