Používáte pro ukládání uživatelských hesel funkce jako MD5 nebo SHA-1 a chtěli byste to změnit na třeba bcrypt? A chcete to udělat pořádně, tedy ještě před únikem, aby byla chráněna všechna hesla ve vaší databázi? Pojďme si ukázat jak to udělat.
Z internetové nákupní galerie Mall.cz unikla uživatelská data včetně zahashovaných hesel. Někdo zatím neznámým způsobem získal přes 750 tisíc účtů, nahrál je na Ulož.to a odkaz v 27. července zveřejnil na Pastebinu. O incidentu informovala firma prostřednictvím poměrně skvělého příspěvku na blogu a e-mailu zákazníkům, ve kterém jim oznamuje, že Mall hesla resetoval a pokud se chtějí znovu přihlásit, tak si musí nastavit heslo nové. Data na Ulož.to již nejsou dostupná, ale Lupa soubor získala a prozkoumala a zjistila, že obsahuje 750 tisíc e-mailových adres a hesel v čitelné podobě. U části zákazníků byla uvedena i telefonní čísla.
Zatím není známé, jakým způsobem útočník získal čitelná hesla (nejspíš je cracknul), Mall totiž údajně hesla hashoval:
Od listopadu 2012 jsme bezpečnost hesel zajišťovali hashovací metodou SHA1 + unikátní solí a od října 2016 chráníme přístupové údaje jednou z nejsilnějších hashovacích metod bcrypt. Do roku 2012 byly údaje hashovány metodou MD5, která dnes již není považována za bezpečnou. Většina prolomených hesel pochází právě z doby, kdy byla používána tato metoda. U starších účtů jsme proto změnili heslo a automaticky je převedli na zmiňovanou nejnovější hashovací metodu bcrypt, kterou aktuálně chráníme přístupové údaje všech účtů.
Pomineme-li, že bcrypt je z roku 1999 a že Mall.cz dříve MD5 zatajil, tak z toho nelze vyčíst, jestli hesla hashovaná pomocí MD5 nějak převáděli na bezpečnější bcrypt. V komentářích pod příspěvkem na blogu pak dodávají, že heslo „přehashovali“ po úspěšném přihlášení uživatele.
Jak to lze udělat lépe, aby ani při případném úniku nebyla ohrožena stará slabě hashovaná hesla uživatelů, kteří se dlouho nepřihlásili? Před několika lety jsme to udělali na Slevomatu a kdyby na to dělala návod IKEA, tak by vypadal asi nějak takhle:
Nejdřív bych měl připomenout, co to vlastně takový na ukládání hesel nevhodný algoritmus je. Jsou to všechny ty MD5, SHA-1, SHA-2, SHA-3 a to v jakékoliv variantě. Se saltem („solí“), nebo bez, „zesílené“ pomocí několika stovek tisíc iterací, nebo jen jedno volání, je to jedno. Na ukládání hesel by se měla použít některá z těchto funkcí: bcrypt, Argon2 (varianta Argon2id nebo Argon2i, ale jen pokud chcete, aby hashování hesla trvalo více než sekundu, pro rychlejší použijte bcrypt), scrypt, nebo PBKDF2. Jsou relativně pomalé, takže pro lamače je časově i finančně náročně hesla cracknout.
Také bych měl zmínit, že tento článek není o reakci na bezpečnostní incident. Pokud už vám jakkoliv uložená hesla unikla (a vy jste si toho všimli), tak je všem uživatelům vyresetujte. Nová hesla pak rovnou ukládejte pomocí „nového“ hashe.
Jestli používáte PHP, tak na uložení použijte funkci password_hash(..., PASSWORD_DEFAULT)
a na ověření password_verify()
. „Algoritmus“ PASSWORD_DEFAULT
aktuálně zajistí použití bcryptu, do budoucna to může být i jiný algoritmus, nicméně hashe uložené dnes půjdou ověřit i po změně defaultního algoritmu. Ten se totiž určuje jen při vytváření hashů, pro ověření se použije nastavení zapsané do samotného hashe, resp. nastavení je součástí výstupu z password_hash()
. Viz např. výsledek z volání password_hash('foo', PASSWORD_DEFAULT)
(což je aktuálně stejný výstup jako z password_hash('foo', PASSWORD_BCRYPT)
, ale hodnota PASSWORD_DEFAULT
se v budoucnu bude měnit):
bcrypt (2y) ┌┐ cost (2¹⁰ = 1024 opakování) ││ ┌┐ $2y$10$7REcgj13ZZTW9XSYGWfZVODMB0uIPn3c2jZmse1kjz7LHGzTdUnGm └────────────────────┘└─────────────────────────────┘ 128-bit salt 184-bit hash obojí nestandardní Base64
Součástí výstupu je také kryptografická sůl (salt), automaticky a správně vygenerovaná funkcí password_hash()
, ručně tedy žádnou další přidávat nemusíte a ani byste neměli, algoritmus byste tím mohli nějak poškodit. Implementace bcryptu v PHP navíc heslo ořízne na maximálně 72 znaků (to nevadí), takže kdybyste před heslo připojili 80 znaků soli, tak k přihlášení nebude heslo vůbec potřeba. O sůl se tedy nemusíte starat, přenechte to funkcím password_hash()
a password_verify()
.
Od PHP 7.2 je dostupný algoritmus Argon2i (PASSWORD_ARGON2I
), doporučovaná varianta Argon2id (PASSWORD_ARGON2ID
) je v PHP od 7.3. Výstup z password_hash(..., PASSWORD_ARGON2ID)
vypadá takto:
algo. ver. parametery ┌───────┐ ┌──┐ ┌────────────┐ $argon2id$v=19$m=1024,t=2,p=2$<128-bit salt>$<256-bit hash>
Salt i hash jsou zakódované do Base64. Verze v
je 1.3 (nejnovější verze Argon2 v době vývoje PHP 7.3) reprezentovaná jako decimální číslo 19, hexadecimálně 0x13. Parametry jsou následující:
m
udává paměťovou náročnost algoritmu (v kilobajtech, od 8p
do 232 – 1)t
, který určuje počet iterací a tím i jak dlouho se bude hashovat (od 1 do 232 – 1)p
určující počet vláken (od 1 do 16777215)V PHP budou mít parametry tyto výchozí hodnoty: 1024 kB zabrané paměti, 2 iterace, 2 vlákna. Generování soli opět nechte na password_hash()
.
Ale zpátky k vylepšování hashování hesel již zaregistrovaných uživatelů. Pokud to chcete udělat, tak máte tyto možnosti:
Pojďme ten třetí způsob trochu rozebrat na atomy. V příkladech se objeví pár PHP funkcí, ale na principu to nic nemění, ten se dá využít i v jiných jazycích nebo prostředích (třeba takhle se to dělá v Djangu). Kód zde uvedený je spíš ukázkou, jak takovou věc udělat, rozhodně ho nekopírujte, tohle není Stack Overflow.
Ujistěte se, že do sloupečku password
se vejde nový hash, doporučuje se nastavit VARCHAR(255)
nebo podobný typ, který pojme alespoň těch 255 znaků, bude se to hodit i pro případné rozšiřování do budoucna.
Budete potřebovat nový sloupeček, ve kterém bude uložen způsob hashování hesla pro toho konkrétního uživatele. Skript na přehashování (viz dále) může běžet klidně i několik dní, takže v databázi budou staré i nové hashe zároveň a přihlašování s tím musí počítat. Ten nový sloupec pojmenujeme např. type
. Nenastavujte NOT NULL
, hodnota NULL
bude určovat starý hash.
Pokud váš starý hash používá unikátní salt pro každého uživatele (statický salt, stejný pro všechny uživatele, není salt), tak budete ještě potřebovat sloupeček, do kterého tento „starý“ salt uložíte, můžeme mu říkat třeba old_salt
.
Tabulku s přihlašovacími údaji není třeba upravovat, typ a případný starý salt si můžete ukládat do jednoho sloupečku společně s hashem a oddělit je třeba dvojtečkou nebo dolarem a při zpracování si je zase „odseknout“. Pro jednoduchost budu používat samostatné sloupečky.
Vlastní přehashování zajistí skript, který spustíte a on najednou „upgraduje“ všechna hesla. Skript vezme třeba tisíc řádků s type IS NULL
a pro každý provede tuhle operaci:
$newHash = password_hash($row->password, PASSWORD_DEFAULT)
$oldSalt
UPDATE
v databázi a uloží $newHash
do sloupce password
(a případně $oldSalt
do sloupce old_salt
), type
nastaví na 1
, ale vše pouze v případě, že typ je NULL
, abychom nepřepsali heslo změněné uživatelem v době od vytažení dat z databáze do přehashováníKód by mohl vypadat nějak takto:
$rows = $db->query('SELECT ... FROM ... WHERE type IS NULL LIMIT 1000');
foreach ($rows as $row) {
$newHash = password_hash($row->password, PASSWORD_DEFAULT);
$oldSalt = ...;
$db->query('UPDATE ... SET password = ?, old_salt = ?, type = 1
WHERE username = ? AND type IS NULL',
$newHash,
$oldSalt,
$row->username
);
}
Doporučoval bych takový skript spustit z příkazové řádky. Může totiž běžet docela dlouho, v případě velkých databází klidně i několik dní. Taky může z nějakého důvodu spadnout a vy ho budete muset spustit znovu. To nebude vadit, s tím se počítá, již přehashovaným heslům se skript vyhne.
Před spuštěním skriptu je potřeba upravit přihlašování, aby počítalo i s novým hashem.
V databázi budeme mít uložen (nový) hash z původního (starého) hashe, takže do funkce na ověření hesel nebudeme posílat heslo zadané uživatelem do formuláře, ale nejdříve musíme znovu spočítat původní (starý) hash a teprve až ten pošleme na ověření. Ověřování ale musí počítat i se zatím nepřevedenými hesly, jinak by se část uživatelů nemohla přihlásit, dokud se jim heslo nepřehashuje.
K rozhodnutí jak uživatele ověřit využijeme obsah sloupce type
. Neprovádějte ověření hesla nejdřív pomocí „nového hashe přes starý“ a pak, v případě selhání, pomocí starého. To je zbytečně pomalé, využijte raději ten sloupeček. Vůbec nevadí, když je způsob hashování známý, stejně musíte předpokládat, že nepřítel systém zná.
Podstatná část kódu:
$row = $db->query('SELECT ... FROM ... WHERE username = ?', $_POST['username']);
switch ($row->type) {
case null: // starý hash
$verified = hash_equals($row->password, sha1($row->old_salt . $_POST['password']));
break;
case 1: // nový hash přes starý
$verified = password_verify(sha1($row->old_salt . $_POST['password']), $row->password);
break;
default:
$verified = false;
break;
}
Pokud starý hash nepotřebuje salt, tak $row->old_salt
samozřejmě vynechejte. Funkce pro bezpečné porovnávání hashů hash_equals()
je dostupná od PHP 5.6, pokud máte starší, tak upgradujte. V nejhorším případě ji můžete nahradit za obyčejné porovnání $row->password === sha1(...)
, to platí i pro ostatní jazyky.
Takovéhle „skládání“ různých hashovacích funkcí není z kryptografického hlediska úplně čisté, běžně se nedoporučuje a není to moc prozkoumáno, ale v tomto případě je mnohem lepší, než používat slabé hashe pro hesla uživatelů, kteří se dlouho nepřihlásí.
Po úspěšném přihlášení má aplikace k dispozici heslo v čitelné podobě, takže ho můžeme zahashovat „čistým“ novým hashem a této kryptografické nedokonalosti se zbavit. Využijeme opět sloupeček type
, aby ověřování hesla vědělo, že tentokrát nemá před voláním password_verify()
dělat žádný cviky. V tomto případě určitě nepoužívejte ověřování stylem nejdřív zkusím čistý nový hash, pak nový přes starý a pak starý, šlo by se totiž přihlásit jen hashem nalezeným v nějaké zveřejněné databázi.
Připravíme si funkci pro uložení nového hashe, nastavení nového typu (2
pro „čistý“ hash) a případné vynulování starého saltu, už ho nebudeme potřebovat:
function saveNewHash($username, $password)
{
$db->query('UPDATE ... SET password = ? , old_salt = NULL, type = 2 WHERE username = ?',
password_hash($password, PASSWORD_DEFAULT),
$username
);
}
A po ověření hesla pomocí nového + starého hashe ji zavoláme. Můžeme ji volat také po ověření jen pomocí starého hashe, ničemu to vadit nebude a aspoň nepatrně ulehčíme skriptu na převod všech hashů. Dále přidáme větev case 2
pro ověření pouze pomocí nového hashe:
$row = $db->query('SELECT ... FROM ... WHERE username = ?', $_POST['username']);
switch ($row->type) {
case null: // starý hash
$verified = hash_equals($row->password, sha1($row->old_salt . $_POST['password']));
if ($verified) {
saveNewHash($_POST['username'], $_POST['password']);
}
break;
case 1: // nový hash přes starý
$verified = password_verify(sha1($row->old_salt . $_POST['password']), $row->password);
if ($verified) {
saveNewHash($_POST['username'], $_POST['password']);
}
break;
case 2: // pouze nový hash
$verified = password_verify($_POST['password'], $row->password);
break;
default:
$verified = false;
break;
}
Náš úžasný skript na přehashování můžeme konečně spustit. Doporučuji ho předtím velmi dobře otestovat a případně si udělat zálohu té správné tabulky, kdyby se něco náhodou nepovedlo. Po doběhnutí skriptu můžete odstranit větev case null
z přihlašování, staré hashe by již v databázi neměly být. Dá se to ověřit pomocí SELECT COUNT(*) ... WHERE type IS NULL
, výsledkem by měla být nula.
Pokud jste si udělali zálohu, tak ji nezapomeňte bezpečně smazat. To se týká i všech ostatních pravidelných záloh databáze, ty také zlikvidujte nebo z nich staré hashe odstraňte. Zálohy se velmi často ztrácejí a mohou být zdrojem úniku starých slabých hashů.
Nezapomeňte při registraci a změně hesla (i zapomenutého) ukládat pouze nový hash a nastavit typ na „pouze nový“ (v našich příkladech to je type = 2
). Tedy v podstatě to, co dělá námi vytvořená funkce saveNewHash($username, $password)
.
Použití silného (a relativně pomalého) hashe samotnému lámání hesel nezabrání, jen útočníkovi bude trvat příliš dlouho, takže ho to snad přestane bavit. Slabá hesla typu password i přesto získá vcelku rychle (protože budou to první, co vyzkouší), takže by bylo fajn mu v crackování nějak zabránit. Občas se doporučuje přimíchat do hesla tzv. pepper („pepř“, jakože sůl a pepř, chápete), tedy další statickou „sůl“ stejnou pro všechny uživatele. Pravděpodobnost, že by útočník získal databázi i pepper z konfigurace, a mohl tak začít crackovat hesla, je o dost menší než že získá jen databázi.
Na pepper zapomeňte, hashovací funkce na jeho použití nejsou navržené a na jeho použití neexistuje žádný rozumný výzkum. Stejného efektu se dá dosáhnout zašifrováním hashů (ne hesel), to je navíc kryptograficky čistá operace. Ale o tom zas někdy příště.
V některém dalším článku si také ukážeme jak pomocí password_needs_rehash()
transparentně měnit parametry hashovacích funkcí, příp. jak změnit algoritmus. V současnosti je použití bcryptu stále v pořádku, hesla ochrání dostatečně i když použijete defaultní cost
. Ten by měl být aspoň 10, od PHP 8.4 bude 12), a dá se změnit způsobem 2), tedy přeuložením po přihlášení, ale nebudeme předbíhat.
Pro ověřování hesel při přihlašování, zvlášť pokud to chcete mít hotové do sekundy, je podle Jeremiho a Steva z komise Password Hashing Competition bcrypt dokonce pořád vhodnější než Argon2.
Prosím, chraňte hesla svých uživatelů.