Stručně: Hesla v PHP ukládejte pomocí password_hash() a ověřujte pomocí password_verify(). Výchozí PASSWORD_DEFAULT používá v současnosti bcrypt, od PHP 8.4 s nákladovým faktorem 12; kde je PHP zkompilováno s Argon2, je dobrou alternativou PASSWORD_ARGON2ID. Staré hashe MD5 nebo SHA-1 převedete tak, že je okamžitě zabalíte do password_hash a při příštím přihlášení nahradíte přímým hashem.
Co má únik databáze společného s vašimi hashi
Po průniku na webserver je databáze jedním z prvních cílů. Kdo může, jako u PHP2Shell, spouštět kód na serveru, přečte i přístupové údaje aplikace, a tím i tabulku s uživatelskými účty. Zda budou hesla v ní čitelná za hodiny, nebo za roky, se nerozhoduje v den útoku, ale při programování.
Překvapivě mnoho aplikací stále ukládá hesla postupy, které pro tento účel nikdy nebyly určeny: MD5, SHA-1, jednoduché SHA-256 nebo dokonce vratné šifrování. Tyto postupy jsou rychlé, a právě to je problém. Útočníci s nimi mohou za sekundu vyzkoušet velmi mnoho kandidátů.
PHP má už dlouho k dispozici řešení, které počítá pomalu, pro každé heslo automaticky vytváří salt a postup i parametry ukládá přímo do hashe. Stačí ho důsledně používat.
password_hash v PHP: bcrypt, nebo Argon2id?
password_hash() zná několik postupů. Výběr podle příručky PHP:
| Konstanta | Postup | Poznámka |
|---|---|---|
PASSWORD_DEFAULT |
v současnosti bcrypt | výchozí, může se v budoucích verzích změnit |
PASSWORD_BCRYPT |
bcrypt | nákladový faktor je od PHP 8.4 ve výchozím stavu 12, dříve 10; počítá se jen prvních 72 bajtů |
PASSWORD_ARGON2I |
Argon2i | od PHP 7.2, jen pokud je PHP sestaveno s podporou Argon2 |
PASSWORD_ARGON2ID |
Argon2id | od PHP 7.3, stejný předpoklad |
OWASP doporučuje jako první volbu Argon2id, s alespoň 19 MiB paměti, dvěma průchody a jedním vláknem, a bcrypt především pro stávající systémy. Pro většinu aplikací v PHP je rozdíl v praxi menší než rozdíl mezi kterýmkoli z těchto postupů a MD5. Důležitější je, že vůbec používáte jeden z nich a parametry pravidelně upravujete.
Zda je Argon2id k dispozici, ukáže krátký test:
<?php
var_dump(defined('PASSWORD_ARGON2ID'));
var_dump(password_algos());
Ukládání a ověřování: kompletní vzor
Následující příklad ukazuje registraci a přihlášení s automatickou aktualizací zastaralých hashů. Kontrola délky chrání před limitem 72 bajtů u bcrypt:
<?php
declare(strict_types=1);
const PASSWORT_ALGO = PASSWORD_DEFAULT;
function passwortSpeichern(string $passwort): string
{
if (strlen($passwort) > 72) {
throw new InvalidArgumentException('Passwort zu lang für das gewählte Verfahren.');
}
return password_hash($passwort, PASSWORT_ALGO);
}
function passwortPruefen(string $eingabe, string $gespeicherterHash, callable $hashAktualisieren): bool
{
if (!password_verify($eingabe, $gespeicherterHash)) {
return false;
}
if (password_needs_rehash($gespeicherterHash, PASSWORT_ALGO)) {
$hashAktualisieren(password_hash($eingabe, PASSWORT_ALGO));
}
return true;
}
strlen() počítá bajty, ne znaky. To je zde záměr, protože limit bcrypt se vztahuje na bajty. Sloupec v databázi by měl podle příručky pojmout více než 60 znaků, 255 je dobrá volba. Nikdy nenastavujte vlastní salt: volba je zastaralá a od PHP 8.0 se ignoruje.
S přechodem na PHP 8.4 stoupá výchozí nákladový faktor pro bcrypt z 10 na 12. Stávající hashe se díky password_needs_rehash() při příštím přihlášení automaticky zvýší. Na silně vytížených systémech se před přechodem vyplatí krátký zátěžový test přihlášení.
Nákladový faktor změřte na svém produkčním serveru. Jedno ověření by mělo stát znatelný výpočetní čas, ale zůstat výrazně pod jednou sekundou, aby se přihlášení nestala při zátěži úzkým hrdlem.
Nahrazení starých hashů MD5 a SHA-1
Skutečnou výzvou je většinou stávající stav. Samotná hesla neznáte, takže je nemůžete jednoduše znovu zahashovat. Čekání, až se každý uživatel znovu přihlásí, nechává neaktivní účty bez ochrany. OWASP proto popisuje dvoufázový postup:
- Okamžitě zabalit: Každou existující hodnotu MD5 nebo SHA-1 skriptem obalit funkcí
password_hash()a uložit s označením, například ve sloupcihash_typ. - Při přihlášení ověřit: U označených účtů vstup nejprve přepočítat starým postupem a poté ověřit pomocí
password_verify()proti obalené hodnotě. - Přímo nahradit: Po úspěšném přihlášení vytvořit z čistého textu normální hash a označení odstranit.
- Vyčistit zbytek: Účty, které mají po stanovené lhůtě stále obalené hashe, nastavit na reset hesla.
<?php
// Fáze 1 (jednorázově skriptem): $neu = password_hash($alterMd5Wert, PASSWORD_DEFAULT);
// Fáze 2 a 3 při přihlášení:
if ($konto['hash_typ'] === 'md5-umhuellt') {
$ok = password_verify(md5($eingabe), $konto['passwort_hash']);
if ($ok) {
$speichere(password_hash($eingabe, PASSWORD_DEFAULT), 'direkt');
}
}
Obalené hashe jsou lepší než holé hodnoty MD5, ale slabší než přímé hashe. Jsou přechodným řešením, ne cílem. Po incidentu, při kterém mohla být dotčena databáze, tato změna nenahrazuje výzvu uživatelům, aby si hesla změnili. Jaké stopy na takový incident ukazují, popisuje stránka Jsem už napaden?.
Pepper, šifrování a jiné slepé uličky
Některé nápady znějí jako více bezpečnosti, ale přinášejí málo nebo škodí:
- Vlastní konstrukce hashů, například vícenásobné SHA-256 s vlastním saltem, jsou rychlejší než bcrypt, a proto slabší.
- Šifrovaná hesla jsou vratná. Pokud klíč leží na stejném serveru, šifrování po průniku sotva chrání.
- Pepper, tedy tajná přidaná hodnota pro všechna hesla, může být další vrstvou, pokud se uchovává odděleně od databáze. Nenahrazuje ale dobrý postup hashování a ztěžuje pozdější změny.
- Předběžné hashování před bcrypt kvůli obejití limitu 72 bajtů přináší podle OWASP vlastní rizika. Jasný limit délky nebo Argon2id je jednodušší.
I u pravidel pro nová hesla se vyplatí podívat na současný stav techniky: délka chrání víc než složité požadavky na speciální znaky a velmi častá hesla nebo hesla, která se už objevila v únicích dat, by se měla odmítat. Vynucené změny v pevných intervalech bez důvodu vedou podle zkušeností spíše k předvídatelným vzorcům než k silnějším heslům.
U přihlašovacích systémů navíc hesla nikdy nestojí samotná: dvoufázové ověření pro administrátory a brzda proti hromadným pokusům o přihlášení ukládání doplňují. Obojí řeší příručka Ochrana přihlášení administrátorů před brute force. Pro odkazy k resetu a jednorázové kódy platí jiná pravidla, viz Bezpečná náhodná čísla a tokeny v PHP. Přehled dalších funkcí podává stránka Vestavěné bezpečnostní funkce PHP.
Jak konkrétně pomáhá PHP SOS?
- Zkontrolujeme, jak vaše aplikace a pluginy ukládají hesla, a najdeme zastaralé postupy i ve vedlejších funkcích.
- Naplánujeme převod stávajícího stavu s obalením, migrací při přihlášení a lhůtou pro zbývající účty.
- Nákladový faktor nebo parametry Argon2 sladíme s vašimi servery.
- Po incidentu podpoříme rozhodování o resetu hesel a jeho technické provedení.
Kontrolu vlastního softwaru v PHP nabízíme jako bezpečnostní audit a penetrační test.
Časté dotazy
Jaký algoritmus používá password_hash s PASSWORD_DEFAULT?
V současnosti bcrypt. Příručka PHP upozorňuje, že se výchozí hodnota může změnit, až přibudou silnější postupy. Sloupec v databázi by proto měl pojmout více než 60 znaků; příručka uvádí jako dobrou volbu 255.
Měl bych hesla šifrovat místo hashování?
Ne. Šifrovaná hesla lze klíčem znovu přečíst a tento klíč leží většinou na stejném serveru. Hash hesla je jednosměrná ulice: aplikace může jen ověřit, zda vstup sedí.
Jak vysoký by měl být nákladový faktor u bcrypt?
Od PHP 8.4 je výchozí hodnota 12, dříve 10. OWASP uvádí 10 jako spodní hranici a doporučuje jít tak vysoko, jak to vaše servery snesou. Změřte na vlastním hardwaru, jak dlouho ověření trvá.
Co znamená limit 72 bajtů u bcrypt?
bcrypt zohledňuje jen prvních 72 bajtů hesla, vše za tím se ořízne. U znaků s diakritikou se jeden znak často počítá za dva bajty. Délku proto výslovně omezte nebo použijte Argon2id, pokud je vaše PHP s ním zkompilováno.
Musí uživatelé po průniku změnit hesla?
Pokud nelze vyloučit, že databáze unikla, měli byste to vyvolat. Dobré hashe výrazně zpomalují prolomení, slabá hesla však zůstávají napadnutelná. Zda navíc existuje oznamovací povinnost, ujasněte s pověřencem pro ochranu osobních údajů.