A 01-02 vas aprendre a reconèixer el prefix eyJ com l'empremta d'un JSON en Base64, i vam descobrir que el token de sessió del portal de MediNube era exactament això: un JSON codificat, sense signatura, que qualsevol podia editar. Vam prometre tornar amb les eines necessàries per arreglar-ho, i aquest dia és avui: tenim HMAC (03-02), signatures asimètriques (04-03) i un gestor de claus (06-01). En aquesta lliçó MediNube salda el seu deute més antic: demostrem l'atac contra el token heretat, entenem què és un JWT de veritat, aprenem a fer-lo servir amb PyJWT esquivant els seus paranys clàssics (alg: none, confusió d'algorismes, claims sense validar) i construïm medinube/sessions.py, el mòdul de sessions definitiu del portal.
Contingut
- El deute de 01-02: el token que qualsevol podia editar
- Sessions amb estat vs tokens autocontinguts
- Anatomia d'un JWT: header, payload, signature
- Signar i verificar amb PyJWT: HS256 i EdDSA
- Els atacs clàssics contra JWT i la seva defensa
- Expiració curta i refresh tokens
- Revocació: el preu de l'autocontingut
- On desar el token al navegador
medinube/sessions.py: la implementació final
El deute de 01-02: el token que qualsevol podia editar
Aquest és el codi heretat que el portal de MediNube ha estat fent servir per a les sessions — el nostre _MALAMENT més antic:
# medinube/sessions_legacy.py — NO FER SERVIR
import base64, json
def crear_sessio_MALAMENT(usuari: str, rol: str) -> str:
dades = {"usuari": usuari, "rol": rol}
return base64.urlsafe_b64encode(json.dumps(dades).encode()).decode()
def llegir_sessio_MALAMENT(token: str) -> dict:
return json.loads(base64.urlsafe_b64decode(token))L'atac no requereix ni criptoanàlisi ni eines: Ana Pérez (o MalloryClinic amb el compte d'Ana) obre la consola del navegador i fa això:
>>> token = crear_sessio_MALAMENT("ana.perez", "pacient")
>>> token
'eyJ1c3VhcmkiOiAiYW5hLnBlcmV6IiwgInJvbCI6ICJwYWNpZW50In0='
# L'"atacant" descodifica, edita i recodifica:
>>> dades = json.loads(base64.urlsafe_b64decode(token))
>>> dades["rol"] = "metge" # ← ascens instantani
>>> fals = base64.urlsafe_b64encode(json.dumps(dades).encode()).decode()
>>> llegir_sessio_MALAMENT(fals)
{'usuari': 'ana.perez', 'rol': 'metge'} # el servidor s'ho creuRegla d'or 4 en la seva versió més cara: codificar no és xifrar... i tampoc és autenticar. Base64 és un canvi d'alfabet, no una protecció. El servidor confia en una dada que ha fabricat el client. La solució no és amagar el contingut (l'usuari pot saber el seu propi rol), és que el servidor pugui verificar que ell mateix ho ha emès i ningú ho ha tocat: una signatura. Això és, exactament, un JWT.
Sessions amb estat vs tokens autocontinguts
Abans de córrer cap a JWT, honestedat d'enginyer: l'alternativa clàssica continua sent excel·lent. Una sessió amb estat desa les dades al servidor i dona al navegador només un identificador aleatori opac (cookie amb 128+ bits del CSPRNG, regla 3 i 5). Un token autocontingut (JWT) porta les dades amb ell, signades.
| Aspecte | Sessió amb estat (cookie + taula) | Token autocontingut (JWT) |
|---|---|---|
| El servidor desa | Una fila per sessió | Res (la clau de signatura) |
| Revocació | Trivial: DELETE FROM sessions |
Difícil (ho veurem: jti + llista) |
| Verificació | Consulta a BD/Redis per petició | Només criptografia, sense I/O |
| Escenari ideal | Aplicació monolítica amb la seva BD al costat | Microserveis, APIs, tercers que verifiquen |
| Contingut visible | No (l'id és opac) | Sí: el payload és llegible per disseny |
| Caducitat | La decideix el servidor en viu | Gravada al token en emetre'l |
La taula diu una cosa incòmoda per a l'hype: si la teva aplicació és un monòlit amb una BD a un mil·lisegon, la cookie de sessió clàssica és més simple i més segura (revocació instantània). JWT guanya quan la verificació ha d'ocórrer lluny de l'emissor: entre els microserveis de MediNube sense tocar la BD a cada salt, o quan la Farmàcia Robles ha de verificar un token emès per MediNube sense trucar-nos. Aquest és el nostre cas d'ús real, així que endavant.
Anatomia d'un JWT: header, payload, signature
Un JWT (JSON Web Token, RFC 7519) són tres blocs Base64URL separats per punts: header.payload.signature. Com que header i payload són JSON, un JWT autèntic ensenya... dos o tres eyJ! Descodifiquem-ne un a mà per perdre-li tot el misteri:
import base64, json
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJhbmEucGVyZXoiLCJyb2wiOiJwYWNpZW50ZSIsImlzcyI6Imh0dHBzOi8v"
"YXBpLm1lZGludWJlLmV4YW1wbGUiLCJleHAiOjE3ODM1MDQwMDB9."
"4v0X9K1v2xLQnO7Yd3rZ8mPqW5tJc6hB0aFgN_sUwEo")
def b64url_decode(part: str) -> bytes:
# Base64URL omet el padding '='; cal reposar-lo per descodificar
return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))
header, payload, signatura = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'ana.perez', 'rol': 'paciente', ...}
# signatura: bytes crus, NO es JSON — es HMAC-SHA256(clau, header + "." + payload)Observa: qualsevol pot llegir el header i el payload (només és Base64URL). La signatura no amaga res, garanteix integritat i origen — com el tag de GCM a 02-03 o la signatura de les receptes a 04-03. Mai posis secrets en un payload JWT.
Els claims estàndard que farem servir sempre:
| Claim | Significat | Per què importa |
|---|---|---|
sub |
Subject: de qui parla el token (ana.perez) |
La identitat |
exp |
Expiration: caducitat (timestamp Unix) | Sense ell, un token robat val per sempre |
iat |
Issued at: moment d'emissió | Auditoria; permet invalidar "tot l'anterior a" |
iss |
Issuer: qui l'ha emès (https://api.medinube.example) |
Evita acceptar tokens d'un altre emissor |
aud |
Audience: per a qui és (farmacia-robles) |
Evita reutilitzar un token d'un servei en un altre |
jti |
JWT id: identificador únic del token | La peça que farà possible la revocació |
Signar i verificar amb PyJWT: HS256 i EdDSA
PyJWT (pip install pyjwt[crypto]) fa la feina. La primera decisió és l'algorisme, i ja coneixes els dos candidats:
- HS256 = HMAC-SHA256 (03-02): simètric. Qui pot verificar també pot emetre. Perfecte entre serveis interns de MediNube que comparteixen la clau (del gestor de 06-01).
- RS256 (RSA-PSS no, compte: RS256 és PKCS#1 v1.5; PS256 és PSS) i EdDSA (Ed25519, 04-03): asimètrics. Es signa amb la privada i es verifica amb la pública. Imprescindible quan verifiquen tercers: la Farmàcia Robles verifica amb la nostra clau pública sense poder emetre tokens — amb HS256 hauríem de donar-li la clau i podria fabricar tokens de MediNube.
import jwt, time
# --- Intern (HS256): entre serveis de MediNube ---
CLAU_HS = obtenir_del_gestor("medinube/produccio/jwt-intern") # 32 bytes CSPRNG
token = jwt.encode(
{"sub": "servei-receptes", "iss": "https://api.medinube.example",
"aud": "servei-historials", "iat": int(time.time()),
"exp": int(time.time()) + 300}, # 5 minuts: intern = curt
CLAU_HS, algorithm="HS256",
)
# --- Extern (EdDSA): la Farmacia Robles verifica sense poder emetre ---
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
privada = carregar_privada_del_gestor("medinube/produccio/jwt-firma-ed25519")
token_farmacia = jwt.encode(
{"sub": "recepta-8842", "iss": "https://api.medinube.example",
"aud": "farmacia-robles", "exp": int(time.time()) + 900},
privada, algorithm="EdDSA",
)
# La Farmacia Robles nomes necessita la clau PUBLICA per verificar:
dades = jwt.decode(token_farmacia, publica_medinube, algorithms=["EdDSA"],
audience="farmacia-robles", issuer="https://api.medinube.example")Explicació de les decisions: les claus surten del gestor (06-01), mai del codi; l'exp intern és de minuts (els tokens interns es demanen i es llencen); i triem EdDSA sobre RS256 per les mateixes raons que a 04-03 — claus i signatures petites, sense decisions de padding que espatllar, rendiment excel·lent.
Els atacs clàssics contra JWT i la seva defensa
La història de JWT és plena d'implementacions trencades. Els tres atacs que has de conèixer ataquen un mateix punt feble: deixar que el token decideixi com es verifica ell mateix.
alg: none: l'especificació original permetia{"alg": "none"}= "sense signatura". Les llibreries antigues acceptaven aquest header... escrit per l'atacant: fabricaves un token amb el payload que volguessis,alg: none, signatura buida, i endins. Éscrear_sessio_MALAMENTdisfressat d'estàndard.- Confusió RS256 → HS256: l'atacant agafa un token RS256 legítim, canvia el header a HS256 i signa amb HMAC fent servir... la clau pública (que és pública) com a clau HMAC. Una llibreria ingènua que rebi "la clau" com un blob i obeeixi l'
algdel token verificarà l'HMAC amb la pública i passarà! La pública era per verificar RSA, no per ser clau simètrica secreta. - Claims sense validar: signar bé i després no comprovar
exp(tokens eterns),aud(un token emès per a la Farmàcia Robles reutilitzat contra l'API d'historials) oiss(acceptar tokens d'un altre emissor amb clau coneguda).
La defensa amb PyJWT modern és una sola disciplina: el verificador fixa les seves expectatives; el token no opina.
def verificar_MALAMENT(token):
# MAI: sense algorithms= (PyJWT modern ho rebutja, versions velles no),
# sense audience, sense issuer... el token mana.
return jwt.decode(token, CLAU, options={"verify_signature": False}) # ← horror
def verificar_be(token: str, publica) -> dict:
return jwt.decode(
token,
publica,
algorithms=["EdDSA"], # llista TANCADA: mata 'none' i la confusio
audience="farmacia-robles", # aquest token es PER a mi
issuer="https://api.medinube.example", # i l'ha emes qui espero
options={"require": ["exp", "iss", "aud", "sub"]}, # claims obligatoris
leeway=30, # 30 s de tolerancia de rellotge
)Punt per punt: algorithms=["EdDSA"] és obligatori sempre — en ser una llista tancada triada per tu, alg: none i el canvi a HS256 moren aquí (PyJWT a més es nega a fer servir una clau pública asimètrica amb algorismes HMAC, defensa en profunditat); audience i issuer tanquen la reutilització entre serveis; require converteix l'absència d'un claim en error en lloc de "no es comprova"; leeway evita falsos rebutjos per rellotges lleugerament desincronitzats. Qualsevol fallada llança una excepció (jwt.InvalidSignatureError, jwt.ExpiredSignatureError...): captura-les i retorna 401, sense filtrar detalls al client.
Expiració curta i refresh tokens
Si un JWT robat és vàlid fins al seu exp, la primera mitigació és òbvia: exp curt (minuts). Però ningú vol tornar-se a autenticar cada 15 minuts. La solució estàndard és la parella access + refresh:
sequenceDiagram
participant N as Navegador (Ana)
participant A as api.medinube.example
participant BD as BD (taula refresh)
N->>A: login (usuari + contrasenya → Argon2id, 03-03)
A->>BD: desa sha256(refresh)
A-->>N: access JWT (15 min) + refresh token (7 dies)
Note over N,A: ...passen 15 minuts, l'access caduca...
N->>A: POST /token/refresh (refresh token)
A->>BD: existeix sha256(refresh) i no esta usat/revocat?
A->>BD: marca el vell com usat, desa sha256(nou)
A-->>N: access nou + refresh NOU (rotacio)
Les peces del disseny, totes conegudes:
- L'access token és el JWT: autocontingut, verificable sense BD, vida de 15 minuts.
- El refresh token no necessita ser un JWT: és un secret opac del CSPRNG (
secrets.token_urlsafe(32)) que sí que viu a la BD... desat com asha256(token), exactament el patró dels tokens de recuperació de 03-03: un bolcat de la BD no regala sessions. - Rotació amb detecció de robatori: cada ús del refresh n'emet un de nou i invalida l'anterior. Si algú presenta un refresh ja usat, hi ha dos posseïdors → es revoca la família sencera i Ana ha de tornar a fer login.
Revocació: el preu de l'autocontingut
"Tancar sessió a tots els dispositius", "aquest empleat ja no treballa aquí", "s'ha filtrat un token": amb sessions de BD és un DELETE; amb JWT, el token signat continua sent criptogràficament vàlid fins al seu exp. No hi ha signatura que "des-signar". Opcions, de menys a més estat:
- TTL curts com a mitigació principal: amb access de 15 minuts, la finestra d'un token robat és de 15 minuts, i la revocació real ocorre al refresh (que sí que és a la BD). És la moralitat de les vides curtes dels certificats de 05-03, calcada: quan revocar és difícil, caduca ràpid.
- Llista de revocats per
jti: una taula/Redis amb elsjtirevocats fins al seuexp. El verificador consulta la llista... i acabes de reintroduir una consulta per petició — l'híbrid honest: només la consulten els endpoints sensibles. - Invalidació per usuari: desar per usuari un
no_valid_abans_de; tot token ambiatanterior mor. Barat (un valor per usuari, cachejable) i cobreix "tanca totes les meves sessions".
MediNube fa servir 1 + 3: access de 15 minuts i marca per usuari per al botó del pànic.
Menció ràpida per completar el mapa: a més de JWS (signat, el que hem fet), existeix JWE (JSON Web Encryption): el token va xifrat, no només signat, per a payloads que el client no ha de llegir. Cinc parts en lloc de tres, mateixa família d'estàndards (JOSE). MediNube no ho necessita — no posem res secret al payload — però l'has de reconèixer quan el vegis.
On desar el token al navegador
Breu i pràctic, perquè aquí es perden moltes batalles guanyades per la criptografia:
localStorage: accessible des de JavaScript → qualsevol XSS exfiltra el token. Evita-ho per a tokens de sessió.- Cookie amb
HttpOnly; Secure; SameSite=Lax(oStrict): JavaScript no la pot llegir (HttpOnlyneutralitza el robatori per XSS), només viatja per HTTPS (Secure, amb l'HSTS de 05-02 al darrere) iSameSitetalla la major part del CSRF. És l'opció per defecte correcta per al portal de MediNube.
El JWT dins d'una cookie HttpOnly combina el millor dels dos mons: verificació sense estat al servidor, robatori per XSS bloquejat al client. Regla d'or 9: la criptografia no substitueix la resta de la seguretat — un token perfectament signat a localStorage continua sent un token robat amb un XSS.
medinube/sessions.py: la implementació final
Tot junt: EdDSA, claims complets, expectatives tancades i kid (key id) al header per poder rotar la clau de signatura sense invalidar les sessions vives — la mateixa jugada que el byte 0x01/0x02 dels historials, ara en JWT:
# medinube/sessions.py — sessions del portal, versio definitiva
import secrets, time, jwt
ISS = "https://portal.medinube.example"
AUD = "portal"
TTL_ACCES = 15 * 60
# Claus de signatura versionades per kid, servides pel gestor (06-01).
# "2026-07" signa; "2026-01" nomes verifica fins que mori el seu ultim token.
CLAUS = {
"2026-07": carregar_parell_ed25519("medinube/produccio/jwt-portal/2026-07"),
"2026-01": carregar_parell_ed25519("medinube/produccio/jwt-portal/2026-01"),
}
KID_ACTIU = "2026-07"
def emetre(usuari: str, rol: str) -> str:
ara = int(time.time())
return jwt.encode(
{"sub": usuari, "rol": rol, "iss": ISS, "aud": AUD,
"iat": ara, "exp": ara + TTL_ACCES, "jti": secrets.token_hex(16)},
CLAUS[KID_ACTIU].privada,
algorithm="EdDSA",
headers={"kid": KID_ACTIU}, # ← quina clau verifica aquest token
)
def verificar(token: str) -> dict:
kid = jwt.get_unverified_header(token).get("kid")
if kid not in CLAUS: # kid desconegut = token rebutjat
raise jwt.InvalidTokenError("kid desconegut")
dades = jwt.decode(
token, CLAUS[kid].publica,
algorithms=["EdDSA"], audience=AUD, issuer=ISS,
options={"require": ["exp", "iat", "sub", "jti"]}, leeway=30,
)
if dades["iat"] < no_valid_abans_de(dades["sub"]): # boto del panic
raise jwt.InvalidTokenError("sessions de l'usuari invalidades")
return dadesL'única dada que es llegeix del token abans de verificar és el kid, i només per triar entre claus en què nosaltres ja confiem (si no és a CLAUS, fora): el token mai no aporta material de verificació, només assenyala quina de les nostres fer servir. Compara aquest mòdul amb crear_sessio_MALAMENT: mateix problema, dos mòduls de curs de distància.
Errors Comuns i Consells
- Error: creure que el JWT està xifrat perquè "no es llegeix". És Base64URL: es llegeix amb dues línies de Python. Res de secret al payload; per a això existeix JWE.
- Error:
jwt.decode(token, clau)sensealgorithms=[...]. A PyJWT modern falla, però en llibreries/versions antigues és la porta d'alg: nonei de la confusió RS256→HS256. Escriu la llista tancada sempre, en tots els llenguatges. - Error: fer servir HS256 amb tercers. Donar la clau de verificació a la Farmàcia Robles és donar-li la d'emissió. Tercers = asimètric (EdDSA/RS256), pública per a ells, privada al gestor.
- Error: access tokens d'hores o dies "per no molestar l'usuari". Per a això hi ha el refresh token; l'access llarg converteix cada robatori en una sessió persistent sense revocació.
- Error: desar el refresh token en clar a la BD.
sha256(token), com els tokens de recuperació de 03-03: verificar no exigeix recuperar. - Consell: registra
jtiisubals logs d'accés (mai el token sencer): auditar "què ha fet aquest token" val or en un incident.
Exercicis
- L'atac, amb les teves mans: fent servir
crear_sessio_MALAMENT, genera el token de("ana.perez", "pacient"), modifica'l per obtenir el rolmetgei verifica quellegir_sessio_MALAMENTl'accepta. Després intenta el mateix atac contra un token emès peremetre()i explica exactament en quina línia deverificar()mor i amb quina excepció. - Auditoria d'un decode: aquest codi verifica els tokens que MediNube emet per a la Farmàcia Robles. Troba els tres problemes:
jwt.decode(token, publica, algorithms=["EdDSA", "HS256"], options={"verify_exp": False}). - Disseny de rotació: descriu (sense codi, 5-8 línies) el procediment complet per rotar la clau de signatura del portal fent servir el
kid, de manera que cap sessió activa no es talli: què es crea, què signa, què només verifica, i quan s'elimina la clau vella.
Solucions
- Amb el token
_MALAMENT, l'atac de la primera secció funciona tal qual. Contraemetre(): en editar el payload, la signatura Ed25519 ja no correspon aheader.payload, ijwt.decode(...)dins deverificar()llançajwt.InvalidSignatureError(la primera comprovació criptogràfica). Per "tornar a signar" necessitaria la clau privada, que viu al gestor. Si a més canviés elkidper un d'inventat, moriria abans, alraisede "kid desconegut". - (a)
algorithms=["EdDSA", "HS256"]: llista no tancada de veritat — permet la confusió d'algorismes: un atacant fabrica un token HS256 signat amb la clau pública (coneguda) com a clau HMAC; la llista ha de ser["EdDSA"]a soles. (b)verify_exp: False: els tokens mai no caduquen; un token filtrat en un log de 2026 continua obrint portes el 2030. (c) Faltenaudienceiissuer: un token emès per a un altre servei (o un altre emissor la pública del qual coneguem) s'acceptaria aquí. Bonus: senseoptions={"require": [...]}, un token senseexppassaria fins i tot ambverify_expactivat. - Es genera el parell nou al gestor sota
kid2027-01→ s'afegeix aCLAUSa tots els verificadors primer (desplegament 1: ningú signa encara amb ella, tots podrien verificar-la) → es canviaKID_ACTIUa la nova (desplegament 2: els tokens nous surten ambkid: 2027-01; els vells continuen verificant amb l'anterior) → s'espera almenysTTL_ACCES+leeway(més la vida del refresh si també signa alguna cosa) → es retira la clau vella deCLAUSi es destrueix al gestor. Cap sessió viva es talla perquè durant tota la finestra les dues claus verifiquen. És rotació sense parada: la mateixa coreografia quev1/v2a 06-01.
Conclusió
El deute més antic del curs està saldat: l'eyJ... de 01-02 era Base64 disfressat de token, i ara el portal emet JWT EdDSA amb claims complets, expectatives tancades a la verificació, access de 15 minuts, refresh rotatori desat com a hash i kid per rotar la signatura sense tallar sessions. Pel camí has après a desconfiar del token que opina sobre la seva pròpia verificació — alg: none i la confusió RS256→HS256 són la mateixa lliçó: el verificador mana.
Amb això, MediNube ja no té peces pendents: tot el que el curs assenyalava com a "heretat" té la seva versió correcta. La propera lliçó és diferent de totes: no aprendràs res de nou — auditaràs. Reunirem tots els _MALAMENT del curs en una galeria d'errors organitzada per famílies, construirem la checklist del revisor i t'enfrontaràs a fragments de codi heretat amb diversos errors combinats, com en una revisió real. És hora d'entrenar la mirada. Ens veiem a l'auditoria.
Curs de Criptografia Aplicada
Mòdul 1: Fonaments de la Criptografia
- Què és la criptografia i per a què serveix
- Codificació, ofuscació i xifratge
- Aleatorietat i entropia
- El principi de Kerckhoffs i les regles d'or
Mòdul 2: Criptografia Simètrica
- Xifratge simètric: AES i ChaCha20
- Modes d'operació
- Xifratge autenticat (AEAD)
- Derivació de claus (KDF)
Mòdul 3: Hashes, MAC i Contrasenyes
- Funcions hash criptogràfiques
- Autenticació de missatges amb HMAC
- Emmagatzematge segur de contrasenyes
Mòdul 4: Criptografia Asimètrica
- Fonaments de clau pública i RSA
- Criptografia de corba el·líptica
- Signatures digitals
- Intercanvi de claus: Diffie-Hellman
- Xifratge híbrid
Mòdul 5: PKI, Certificats i TLS
- Certificats X.509 i autoritats de certificació
- TLS a la pràctica
- Gestió del cicle de vida dels certificats
