Hashing

Com fer hashing de contrasenyes a Laravel: bcrypt, Argon2, verificació, rehashing automàtic i bones pràctiques de seguretat.

Què és el hashing?#

El hashing és una transformació matemàtica unidireccional: converteix una entrada (com una contrasenya) en una cadena de longitud fixa (el hash) que no es pot revertir per obtenir l'entrada original. Això és fonamentalment diferent de l'encriptació, que és reversible. Amb l'encriptació, pots recuperar les dades originals si tens la clau. Amb el hashing, no hi ha cap clau ni cap manera de tornar enrere: la transformació és permanent.

Aquesta irreversibilitat és exactament el que fa que el hashing sigui perfecte per a contrasenyes. Quan un usuari crea un compte, la seva contrasenya es transforma en un hash i es guarda a la base de dades. Quan l'usuari inicia sessió, la contrasenya introduïda es transforma amb el mateix algoritme i es compara amb el hash guardat. Si coincideixen, la contrasenya és correcta. En cap moment del procés la contrasenya en text pla es guarda a la base de dades.

Si un atacant accedeix a la base de dades, només veu els hashos, no les contrasenyes. No pot revertir el hash per obtenir la contrasenya original. L'única opció seria provar milions de contrasenyes possibles, calcular el hash de cadascuna i comparar-lo amb els hashos de la base de dades (un atac de força bruta). Per això els algoritmes de hashing de contrasenyes (bcrypt, Argon2) estan dissenyats per ser deliberadament lents: cada hash triga centenars de mil·lisegons, fent que un atac de força bruta trigui anys o dècades.

La façana Hash#

Laravel proporciona la façana Hash per crear i verificar hashos de contrasenyes. La interfície és senzilla perquè els detalls criptogràfics estan encapsulats dins de l'algoritme:

use Illuminate\Support\Facades\Hash;
 
// Crear un hash d'una contrasenya
$hashed = Hash::make('la-meva-contrasenya-segura');
// $2y$12$eF5W1vK0...cadena_molt_llarga...
 
// Verificar que una contrasenya coincideix amb un hash
if (Hash::check('la-meva-contrasenya-segura', $hashed)) {
    // La contrasenya és correcta
}
 
if (Hash::check('contrasenya-incorrecta', $hashed)) {
    // Això mai s'executarà
}

Cada vegada que crides Hash::make() amb la mateixa contrasenya, el resultat és diferent. Això és perquè l'algoritme genera un salt aleatori per a cada hash. El salt s'inclou dins del hash resultant, de manera que Hash::check() pot verificar la contrasenya correctament sense necessitat de guardar el salt per separat.

Aquesta propietat és important per a la seguretat: si dos usuaris tenen la mateixa contrasenya, els seus hashos seran completament diferents. Un atacant no pot comparar hashos per detectar contrasenyes idèntiques.

Bcrypt#

Bcrypt és l'algoritme de hashing per defecte de Laravel. Va ser dissenyat específicament per a contrasenyes el 1999 per Niels Provos i David Mazières, basat en el xifrat Blowfish. La seva característica principal és el paràmetre de "cost" (o "rounds") que controla quant de lent és l'algoritme:

// Bcrypt amb el cost per defecte (12 rounds)
$hashed = Hash::make('password');
 
// Bcrypt amb un cost personalitzat
$hashed = Hash::make('password', [
    'rounds' => 14,
]);

El cost determina quantes iteracions fa l'algoritme internament. Cada increment del cost duplica el temps de càlcul:

| Cost (rounds) | Temps aproximat | |---|---| | 4 | ~1 ms | | 10 | ~50 ms | | 12 | ~200 ms (defecte) | | 14 | ~800 ms | | 16 | ~3 segons |

El cost per defecte de 12 és un bon equilibri entre seguretat i rendiment per a la majoria d'aplicacions. Cada hash triga uns 200ms, cosa que és imperceptible per a l'usuari que inicia sessió però fa que un atac de força bruta amb mil milions de contrasenyes trigui segles.

La configuració del cost es fa al fitxer config/hashing.php:

// config/hashing.php
return [
    'driver' => 'bcrypt',
 
    'bcrypt' => [
        'rounds' => env('BCRYPT_ROUNDS', 12),
        'verify' => true,
    ],
];

En entorns de testing, és habitual reduir el cost a 4 per accelerar els tests. Cada test que crea un usuari ha de fer hash de la contrasenya, i amb cost 12, això triga 200ms per hash. Amb 100 tests que creen usuaris, el hash afegeix 20 segons a la suite:

# .env.testing
BCRYPT_ROUNDS=4

Argon2#

Argon2 és un algoritme de hashing més modern que va guanyar la Password Hashing Competition el 2015. A diferència de bcrypt que només permet ajustar el temps de càlcul, Argon2 permet configurar tres paràmetres independents: memòria, temps i paral·lelisme. Això el fa més resistent a atacs amb hardware especialitzat (GPUs, ASICs):

// Configurar Argon2 com a driver
// config/hashing.php
return [
    'driver' => 'argon2id',
 
    'argon' => [
        'memory' => 65536,    // 64 MB de memòria
        'threads' => 1,        // Nombre de fils
        'time' => 4,           // Iteracions
        'verify' => true,
    ],
];

Laravel suporta dues variants d'Argon2:

  • Argon2i: optimitzat per resistir atacs de canal lateral (side-channel attacks)
  • Argon2id: combinació d'Argon2i i Argon2d, recomanat per la majoria d'aplicacions
// Usar Argon2 manualment
$hashed = Hash::make('password', [
    'memory' => 65536,
    'time' => 4,
    'threads' => 1,
]);

Bcrypt vs Argon2#

Tots dos algoritmes són segurs per a contrasenyes. La diferència principal és que Argon2 ofereix més paràmetres de configuració i és més resistent a atacs amb GPUs perquè requereix molta memòria RAM (cosa que les GPUs no tenen en abundància). Bcrypt és més madur, àmpliament suportat i provat durant més de 25 anys.

Per a la majoria d'aplicacions, bcrypt amb cost 12 és perfectament adequat. Argon2 és la millor opció si tens requisits de seguretat especialment alts o si vols protecció addicional contra atacs amb hardware especialitzat.

Verificació de contrasenyes#

La verificació de contrasenyes és el procés de comparar una contrasenya en text pla amb un hash guardat. Laravel proporciona el mètode Hash::check() per a això:

use Illuminate\Support\Facades\Hash;
 
// A un controlador de login
public function login(Request $request)
{
    $user = User::where('email', $request->email)->first();
 
    if (! $user || ! Hash::check($request->password, $user->password)) {
        return back()->withErrors([
            'email' => 'Les credencials proporcionades no són correctes.',
        ]);
    }
 
    // Login correcte
    Auth::login($user);
    return redirect('/dashboard');
}

En la pràctica, rarament necessites cridar Hash::check() directament. El sistema d'autenticació de Laravel (i els starter kits com Breeze i Jetstream) gestionen la verificació automàticament. El codi anterior és per il·lustrar com funciona internament.

Timing-safe comparison#

Hash::check() utilitza una comparació segura contra atacs de temporització (timing attacks). En una comparació normal, el temps de resposta varia segons quants caràcters coincideixen. Un atacant podria mesurar el temps de resposta per endevinar el hash caràcter a caràcter. Hash::check() triga sempre el mateix temps independentment de quants caràcters coincideixin.

Rehashing automàtic#

Amb el temps, el hardware es fa més ràpid i el cost que era adequat fa 5 anys pot ser insuficient avui. Laravel pot verificar automàticament si un hash necessita ser recalculat amb els paràmetres actuals i fer-ho de manera transparent:

use Illuminate\Support\Facades\Hash;
 
// Verificar si el hash necessita ser recalculat
if (Hash::needsRehash($hashed)) {
    $newHash = Hash::make('la-contrasenya-original');
    // Guardar el nou hash a la base de dades
}

Hash::needsRehash() retorna true si el hash va ser creat amb un cost inferior a l'actual. Per exemple, si el hash es va crear amb cost 10 i ara el cost per defecte és 12, needsRehash() retorna true.

Laravel 11 pot fer rehash automàticament durant el login. Quan l'opció rehash_on_login està activada a la configuració de hashing, Laravel verifica i actualitza automàticament el hash de la contrasenya cada vegada que l'usuari inicia sessió:

// config/hashing.php
'rehash_on_login' => true,

Amb aquesta configuració, si augmentes el cost de bcrypt de 12 a 14, els hashos es recalcularan progressivament a mesura que els usuaris vagin iniciant sessió. No cal una migració massiva: el procés és gradual i transparent.

Hashing de dades que no són contrasenyes#

El hashing no és exclusiu de les contrasenyes. És útil per a qualsevol situació on necessites verificar la integritat d'una dada sense poder recuperar-la. Alguns exemples:

// Hash per a tokens de verificació d'email
$token = Str::random(64);
$hashedToken = hash('sha256', $token);
 
// Guardar el hash a la BD, enviar el token per email
DB::table('email_verifications')->insert([
    'user_id' => $user->id,
    'token' => $hashedToken,  // Hash, no text pla
]);
 
// Verificar: calcular el hash del token rebut i comparar
$isValid = DB::table('email_verifications')
    ->where('user_id', $user->id)
    ->where('token', hash('sha256', $receivedToken))
    ->exists();
// Hash per a claus d'API (per poder cercar a la BD)
$apiKey = Str::random(40);
$hashedKey = hash('sha256', $apiKey);
 
$user->api_keys()->create([
    'key_hash' => $hashedKey,
    'name' => 'Clau de producció',
]);
 
// Mostrar la clau a l'usuari NOMÉS un cop (no es pot recuperar)
return response()->json(['api_key' => $apiKey]);
 
// Verificar: cercar per hash
$apiKeyRecord = ApiKey::where('key_hash', hash('sha256', $providedKey))->first();

Per a tokens i claus d'API, hash('sha256', ...) és adequat perquè els tokens ja són aleatoris i no necessiten la protecció contra força bruta que proporciona bcrypt. Bcrypt/Argon2 s'utilitzen per a contrasenyes perquè les contrasenyes tendeixen a ser curtes i predictibles (paraules del diccionari, patrons comuns), cosa que les fa vulnerables a força bruta si el hash és ràpid.

Bones pràctiques#

Mai guardar contrasenyes en text pla: pot semblar obvi, però segueix sent un dels errors de seguretat més comuns. Sempre utilitza Hash::make() o el sistema d'autenticació de Laravel.

No utilitzar MD5 ni SHA1 per a contrasenyes: MD5 i SHA1 són funcions de hash ràpides dissenyades per a verificació d'integritat (checksums), no per a contrasenyes. Són tan ràpides que un atacant amb una GPU pot provar mil milions de contrasenyes per segon. Bcrypt i Argon2 estan dissenyats per ser lents específicament per a aquest propòsit.

Augmentar el cost periòdicament: a mesura que el hardware millora, augmenta el cost de bcrypt o els paràmetres d'Argon2. Amb rehash_on_login activat, els hashos s'actualitzen gradualment sense intervenció manual.

No limitar la longitud de les contrasenyes: bcrypt treballa internament amb els primers 72 bytes de la contrasenya. Això és més que suficient per a qualsevol contrasenya raonable (72 bytes = 72 caràcters ASCII). No imposis límits artificials de longitud (com "màxim 20 caràcters") que redueixin la seguretat sense cap benefici.

Utilitzar regles de validació adequades: Laravel proporciona la regla Password per validar la fortalesa de les contrasenyes:

use Illuminate\Validation\Rules\Password;
 
$request->validate([
    'password' => [
        'required',
        'confirmed',
        Password::min(8)
            ->mixedCase()        // Majúscules i minúscules
            ->numbers()          // Almenys un número
            ->uncompromised(),   // No apareix en bases de dades filtrades
    ],
]);

La regla uncompromised() comprova la contrasenya contra l'API de Have I Been Pwned per verificar que no apareix en cap filtració de dades coneguda. Aquesta verificació utilitza un protocol de k-anonymity que no envia la contrasenya al servei extern: només envia els primers 5 caràcters del hash SHA-1 i rep una llista de hashos que coincideixen amb aquell prefix, comparant localment.