Криптография
Три небольших, узконаправленных класса, покрывающих 99 % того, что веб-приложению нужно делать с криптографией — без подтягивания 5-мегабайтного стека зависимостей вроде Sodium/libsodium/Defuse:
| Класс | Решает | Алгоритм |
|---|---|---|
Encrypter |
Шифрование и аутентификация данных в покое | AES-256-GCM |
Hasher |
Хеширование паролей (одностороннее, намеренно медленное) | Argon2id (по умолчанию) / Argon2i / bcrypt |
Signer |
Подпись данных, чтобы их можно было проверить позже | HMAC (по умолчанию SHA-256) |
Все три безсостоятельны и безопасны для регистрации как синглтоны. Все сравнения используют примитивы, устойчивые к тайминг-атакам (hash_equals, password_verify, проверка тега GCM).
Ментальная модель: выбирайте по намерению. «Хочу это потом прочитать» →
Encrypter. «Хочу доказать, что это моё, но не прятать» →Signer. «Сравнить пароль, никогда его не восстанавливая» →Hasher.
Когда что использовать
| Потребность | Использовать |
|---|---|
| Хранить секреты в покое (API-токены, персональные данные) | Encrypter |
| Подписывать параметры URL, cookie, непрозрачные билеты | Signer |
| Хешировать пароли пользователей | Hasher |
| Безсостоятельные токены аутентификации API с claim | JWT |
Если вас тянет написать md5($password) — остановитесь, выдохните, используйте Hasher.
Encrypter — AES-256-GCM
Аутентифицированное симметричное шифрование. AES-256-GCM обеспечивает и конфиденциальность (никто не может это прочитать), и целостность (никто не может это подделать). Расшифровка с неверным ключом или перевёрнутым битом выбрасывает исключение — никогда не портит данные молча.
Формат хранения (затем кодируется в base64):
[12-байтовый IV][16-байтовый тег GCM][шифротекст переменной длины]
Быстрый старт
use Lift\Crypto\Encrypter;
// Один раз: сгенерируйте ключ и сохраните в base64
$key = base64_encode(Encrypter::generateKey()); // положите в env-переменную APP_KEY
// При загрузке:
$encrypter = new Encrypter(base64_decode($_ENV['APP_KEY']));
// Зашифровать что угодно
$ciphertext = $encrypter->encrypt('hunter2');
$plaintext = $encrypter->decrypt($ciphertext); // 'hunter2'
Сгенерируйте ключ
php -r "require 'vendor/autoload.php'; echo base64_encode(\Lift\Crypto\Encrypter::generateKey()) . PHP_EOL;"
# d6vK2tBh+RDxYTPbAv1mZ+iD1mPj5L0eR2RhYZmDcNk=
Поместите результат в .env как APP_KEY=…. Никогда не коммитьте. Никогда не логируйте.
Свойства вывода
- Разный шифротекст каждый раз даже для одного и того же открытого текста (случайный IV на каждый вызов). Не используйте его как ключ дедупликации.
- Аутентифицирован. Подделка где-либо →
RuntimeException: Decryption failed: authentication tag mismatch. - Закодирован в base64, поэтому безопасен в URL / cookie / JSON / столбцах базы данных.
- Около 28 байт накладных расходов по сравнению с сырым открытым текстом (IV + тег + дополнение base64).
Реальные паттерны
Зашифрованный столбец
$encrypter = $app->make(Encrypter::class);
$db->table('users')->insert([
'email' => $email,
'oauth_token' => $encrypter->encrypt($accessToken), // хранится зашифрованным
]);
$row = $db->table('users')->where('id', $id)->first();
$accessToken = $encrypter->decrypt($row['oauth_token']);
Зашифрованная cookie
return Response::json($data)
->withCookie('session_extra', $encrypter->encrypt(json_encode($payload)), [
'http_only' => true,
'secure' => true,
'same_site' => 'Lax',
]);
Одноразовая ссылка с зашифрованной полезной нагрузкой
// Сгенерировать
$url = '/reset?t=' . urlencode($encrypter->encrypt(json_encode([
'user_id' => 42,
'exp' => time() + 900,
])));
// Проверить
try {
$payload = json_decode($encrypter->decrypt($req->query('t')), true);
if ($payload['exp'] < time()) throw new \RuntimeException('expired');
} catch (\Throwable) {
return Response::json(['error' => 'Invalid link'], 400);
}
Для токенов, которые должны быть читаемы клиенту (например, ему нужно видеть user-id), используйте вместо этого Signer — шифрование избыточно.
Ротация ключей
Если APP_KEY когда-либо утечёт, ротируйте:
- Сгенерируйте новый ключ.
- Держите старый ключ доступным как
APP_KEY_PREVIOUS. - При неудаче
decrypt()с новым ключом попробуйте предыдущий. Если он сработал, перешифруйте новым ключом и сохраните.
try {
$plaintext = $newEncrypter->decrypt($ciphertext);
} catch (\RuntimeException) {
$plaintext = $oldEncrypter->decrypt($ciphertext); // переходный период
$ciphertext = $newEncrypter->encrypt($plaintext); // сохранить новое
}
Через две недели удалите старый ключ.
Ограничения
- Длина открытого текста: практически неограниченна (мы никогда не загружаем больше одного раза в память; ограничение — то, что позволяет PHP).
- Не пытайтесь шифровать потоки этим классом — он одноразовый. Для больших файлов шифруйте чанки по отдельности.
Hasher — хеширование паролей
Оборачивает нативные password_hash() / password_verify() PHP. Argon2id — по умолчанию: рекомендован OWASP, устойчив и к GPU-перебору, и к атакам по сторонним каналам.
Быстрый старт
use Lift\Crypto\Hasher;
$hasher = new Hasher(); // Argon2id, опции по умолчанию
// Хешировать при регистрации
$hash = $hasher->hash('hunter2');
$db->table('users')->insert(['email' => $email, 'password_hash' => $hash]);
// Проверить при входе
$user = $db->table('users')->where('email', $email)->first();
if ($user === null || !$hasher->verify($plainPassword, $user['password_hash'])) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
Почему Argon2id
| Алгоритм | Memory-hard | Устойчив к GPU | Рекомендован OWASP |
|---|---|---|---|
| MD5 / SHA-* | ❌ | ❌ | НИКОГДА |
| bcrypt | частично | ⚠️ (современные GPU помогают) | OK |
| Argon2i | ✅ | ✅ | ✅ |
| Argon2id | ✅ | ✅ | ✅ по умолчанию |
Argon2id смешивает Argon2i (устойчив к сторонним каналам) и Argon2d (устойчив к GPU) — лучшее из обоих. Используйте его, если только у вас нет жёсткого ограничения на поддержку легаси.
Настройка стоимости
Параметры Argon2 контролируют, сколько CPU/RAM стоит хеширование (и, следовательно, насколько медленно его взломать). Значения по умолчанию хороши; для приложений с высокими требованиями к безопасности увеличьте их:
$hasher = new Hasher(
algorithm: Algorithm::Argon2id,
options: [
'memory_cost' => 65_536, // КиБ — 64 МиБ
'time_cost' => 4, // итерации
'threads' => 2,
],
);
Стремитесь ко времени хеширования ≈ 100–300 мс на вашем продакшен-железе. Замерьте:
$start = hrtime(true);
$hasher->hash('test');
$ms = (hrtime(true) - $start) / 1e6;
echo "hash took {$ms} ms\n";
bcrypt для совместимости с легаси
$hasher = new Hasher(algorithm: Algorithm::Bcrypt, options: ['cost' => 12]);
Примечания:
- bcrypt молча обрезает ввод до 72 байт — многобайтовые пароли могут потерять энтропию. Argon2 — нет.
- Фактор стоимости 12 — минимальное современное руководство; 14+ для приложений, чувствительных к безопасности.
Повторное хеширование при входе
Когда вы повышаете факторы стоимости Argon2 (или переходите с bcrypt на Argon2id), пользователей со старым хешем нужно обновлять прозрачно:
if ($hasher->verify($plainPassword, $row['password_hash'])) {
if ($hasher->needsRehash($row['password_hash'])) {
$newHash = $hasher->hash($plainPassword);
$db->table('users')->where('id', $row['id'])->update(['password_hash' => $newHash]);
}
// … выполнить вход …
}
needsRehash() возвращает true, когда сохранённый хеш использует более слабый алгоритм или меньшую стоимость, чем текущая конфигурация Hasher.
Signer — HMAC
Signer не прячет данные — он доказывает, что данные пришли от вас. Сценарии:
- Подписанные параметры URL (
/files?id=42&exp=…&sig=…) - Безсостоятельные одноразовые токены (сброс пароля, подтверждение email)
- Подписи вебхуков (
X-Signature: sha256=…) - Cookie, которые клиент должен читать, но не изменять
Быстрый старт
use Lift\Crypto\Signer;
$signer = new Signer($_ENV['APP_SECRET']); // любой непустой секрет
$signer = new Signer($_ENV['APP_SECRET'], 'sha512'); // любой алгоритм из hash_hmac_algos()
Сырая подпись / проверка
$payload = $userId . '|' . $exp;
$sig = $signer->sign($payload); // 64-символьный hex
$ok = $signer->verify($payload, $sig); // bool, устойчиво к таймингу
if (!$ok) { /* подделано */ }
Самодостаточные токены
Высокоуровневый помощник упаковывает полезную нагрузку + подпись в одну URL-безопасную строку:
$token = $signer->signToken([
'user_id' => 42,
'action' => 'reset_password',
'exp' => time() + 900,
]);
// → 'eyJ1c2VyX2lkIjo0Mn0.b6f3c0a9…'
try {
$payload = $signer->verifyToken($token); // возвращает исходный массив
if ($payload['exp'] < time()) throw new \RuntimeException('expired');
} catch (\RuntimeException $e) {
// плохая подпись, искажён или истёк
}
signToken() / verifyToken() отличаются от JWT двумя вещами:
- Нет заголовка — алгоритм фиксирован экземпляром
Signer(никаких атак на путаницуalg). - Полезная нагрузка — это JSON, закодированный в base64url. Не зашифрована — любой может её прочитать.
Для совместимости с другими системами, которые ожидают стандартные JWT, используйте вместо этого JWT.
Проверка вебхуков
Распространённый паттерн: вы принимаете вебхуки, подписанные HMAC-SHA256 сырого тела.
$app->post('/webhook', function (Request $req) use ($signer) {
$raw = (string) $req->getBody();
$sig = $req->getHeaderLine('X-Signature');
if (!$signer->verify($raw, $sig)) {
throw new \Lift\Exception\UnauthorizedException("Bad signature");
}
$event = json_decode($raw, true);
// … обработать …
return Response::noContent();
});
Всегда подписывайте и проверяйте сырые байты, а не разобранное тело. Повторный разбор меняет пробелы, порядок ключей и т. д. — и подпись не совпадёт.
Подписанные ссылки на скачивание
$expires = time() + 60;
$signature = $signer->sign("download:$fileId:$expires");
$url = "/download?id={$fileId}&exp={$expires}&sig={$signature}";
// Обработчик
$app->get('/download', function (Request $req) use ($signer, $fs) {
$id = (int) $req->query('id');
$exp = (int) $req->query('exp');
$sig = $req->query('sig', '');
if ($exp < time() || !$signer->verify("download:{$id}:{$exp}", $sig)) {
return Response::json(['error' => 'Link expired or invalid'], 403);
}
// … передать файл потоком …
});
Пользователь может скопировать URL; без секрета он не сможет подделать новый.
Регистрация всего в контейнере
use Lift\Crypto\Encrypter;
use Lift\Crypto\Hasher;
use Lift\Crypto\Signer;
$app->singleton(Encrypter::class, fn() => new Encrypter(base64_decode($_ENV['APP_KEY'])));
$app->singleton(Hasher::class); // автосвязывается со значениями по умолчанию
$app->singleton(Signer::class, fn() => new Signer($_ENV['APP_SECRET']));
Затем где угодно — обработчик, контроллер, сервис — укажите тип класса:
class TokenService
{
public function __construct(
private readonly Encrypter $cipher,
private readonly Signer $signer,
) {}
}
Чек-лист безопасности
- ✅ Ключи живут в env-переменных / менеджере секретов, никогда в git.
- ✅
APP_KEY(Encrypter) — это 32 сырых байта (= 44-символьный base64). - ✅
APP_SECRET(Signer / CSRF) — ≥ 32 случайных байт. - ✅ Используйте Argon2id для паролей. Перехешируйте при входе, когда
needsRehash()возвращает true. - ✅ Подписывайте сырые байты для проверки вебхуков; никогда разобранный JSON.
- ✅ Всегда проверяйте срок действия наряду с подписью (claim
expв токене,?exp=…в URL). - ❌ Не пишите собственный криптоалгоритм — эти классы уже оборачивают правильные примитивы.
- ❌ Не делайте
md5/sha1паролей. Никогда. (Да, даже с «солью».) - ❌ Не сравнивайте хеши/подписи через
==. Используйтеhash_equals()(чтоSignerделает внутренне). - ❌ Не переиспользуйте один ключ для нескольких целей (шифрование vs подпись vs cookie) — используйте отдельные env-переменные.
Частые подводные камни
| Симптом | Причина | Исправление |
|---|---|---|
InvalidArgumentException: Encryption key must be exactly 32 bytes |
Вы передали base64-строку, а не декодированные байты | new Encrypter(base64_decode($_ENV['APP_KEY'])). |
Decryption failed: authentication tag mismatch |
Неверный ключ, или кто-то перевернул бит (или вы закодировали в base64 дважды) | Перепроверьте ключ; никогда не редактируйте шифротекст вручную. |
password_verify() возвращает false для правильного пароля |
bcrypt обрезал пароль до 72 байт при хешировании | Перейдите на Argon2id и перехешируйте всех при следующем входе. |
| Подпись вебхука не проходит | Вы проверяли против json_decode($body), а не сырых байт |
Используйте (string) $req->getBody() ДО любого разбора. |
Hasher::hash() занимает 2 секунды |
Стоимость Argon2 очень высока | Снизьте memory_cost / time_cost; стремитесь к ~200 мс. |
| URL с подписью раскрывают user_id | Полезная нагрузка — это JSON в base64, не зашифрована | Используйте Encrypter, если нужно скрыть содержимое. |
Шпаргалка
// Encrypter
$e = new Encrypter(base64_decode($_ENV['APP_KEY']));
$ct = $e->encrypt('secret');
$pt = $e->decrypt($ct);
// Hasher
$h = new Hasher(); // Argon2id по умолчанию
$hash = $h->hash($password);
$h->verify($password, $hash); // bool
$h->needsRehash($hash); // bool, после обновления
// Signer
$s = new Signer($_ENV['APP_SECRET']);
$sig = $s->sign($data); // hex
$s->verify($data, $sig); // bool, устойчиво к таймингу
$tok = $s->signToken(['user_id' => 1, 'exp' => time() + 60]);
$payload = $s->verifyToken($tok);