v0.148.0
 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}"