Senior: питання на співбесіді з теми «Автентифікація й секрети»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Три різні задачі, які часто плутають:
Хешування - одностороннє перетворення даних у відбиток фіксованої довжини. Відновити дані з хешу неможливо.
- Перевірка цілісності: чи не змінився файл (SHA-256).
- Паролі - але лише повільними функціями з сіллю (Argon2id, bcrypt), не швидкими.
Шифрування - двостороннє: з ключем дані можна розшифрувати.
- Симетричне (AES-GCM, ChaCha20-Poly1305): один ключ шифрує й розшифровує. Швидке; для даних у базі, файлів, cookie.
- Асиметричне (RSA, X25519): публічний ключ шифрує, лише приватний розшифровує. Для обміну ключами й шифрування для конкретного отримувача.
Використовують автентифіковане шифрування (AEAD): воно не лише приховує дані, а й виявляє їхню зміну. Шифрування без перевірки цілісності (AES-CBC без MAC) вразливе до підміни шифротексту.
Підпис / MAC - доводить, що дані не змінено і створено тим, хто має ключ. Дані при цьому лишаються відкритими.
- HMAC (симетричний): спільний секрет - підписані URL, підписи вебхуків, JWT з HS256.
- Цифровий підпис (Ed25519, ECDSA, RSA): приватний ключ підписує, будь-хто з публічним - перевіряє. JWT з RS256/EdDSA, підписи релізів, TLS-сертифікати.
У Laravel:
Crypt::encryptString($iban); // AES-256-CBC + HMAC (або GCM): шифрування з перевіркою цілісності
Hash::make($password); // повільний хеш пароля
URL::signedRoute('unsubscribe', ['user' => $id]); // HMAC-підпис
Головні правила: не винаходити власну криптографію, використовувати перевірені бібліотеки (libsodium, вбудовані засоби фреймворку), зберігати ключі окремо від даних і мати план ротації ключів.
Застосунок - це ваш код плюс сотні пакетів Composer і npm, кожен з яких може мати власні залежності. Атака на ланцюжок постачання - компрометація не вашого коду, а чогось, від чого він залежить: пакета, інструмента збирання, CI.
Як це відбувається:
- Викрадений обліковий запис мейнтейнера - і в популярний пакет потрапляє шкідлива версія.
- Typosquatting - пакет з назвою, схожою на популярну.
- Dependency confusion - публічний пакет з назвою вашого внутрішнього, який менеджер пакетів вибирає замість приватного.
- Шкідливі скрипти встановлення -
postinstallу npm виконується одразу під часnpm install. - Компрометація CI - секрети з пайплайна, підміна артефактів збирання.
Захист:
- Lock-файли в репозиторії (
composer.lock,package-lock.json) і встановлення лише з них у CI й на проді (composer install,npm ci). - Аудит вразливостей:
composer audit,npm audit, Dependabot чи Renovate з автоматичними pull request на оновлення. - Оновлення - регулярно, але не наосліп: читати changelog, дати новій версії кілька днів «відлежатися», переглядати diff у критичних пакетах.
- Менше залежностей: кожен пакет - ще одна точка довіри. Для дрібної функції часто простіше написати кілька рядків, ніж додати пакет.
- Обмеження скриптів:
npm ci --ignore-scripts, де можливо; у Composer плагіни потребують явного дозволу (allow-plugins). - Захист CI: мінімальні права токенів, секрети лише для гілок, яким довіряєте, закріплення сторонніх GitHub Actions за хешем коміту, а не тегом.
- Перевірка походження: підписи й attestations пакетів (npm provenance, Sigstore), SBOM для розуміння, що саме в застосунку.
Висновок: залежність - це чужий код, який ви запускаєте з повними правами застосунку. Ставитися до неї варто відповідно.
Докладніше в документації: OWASP: керування вразливими залежностями
Атака за часом - зловмисник вимірює, скільки часу сервер обробляє запит, і з різниці робить висновки про секрет.
Класичний приклад - порівняння рядків:
if ($providedToken === $storedToken) { ... }
Звичайне порівняння зупиняється на першому символі, що не збігся. Токен, у якого правильні перші 10 символів, перевіряється трохи довше, ніж токен з неправильним першим символом. Вимірюючи час тисяч запитів і усереднюючи шум, теоретично можна підбирати токен посимвольно: замість 62^32 варіантів - 62 × 32 спроби.
hash_equals($known, $user) порівнює рядки за постійний час - незалежно від того, де саме відрізняються символи:
if (hash_equals($storedSignature, $providedSignature)) { ... }
Порядок аргументів важливий: перший - відоме (секретне) значення, другий - від користувача.
Де потрібне порівняння в постійному часі:
- підписи вебхуків і запитів (HMAC);
- API-ключі й токени, якщо порівнюються як рядки;
- коди підтвердження, CSRF-токени;
- підписані URL.
Laravel використовує hash_equals для перевірки CSRF-токенів, підписаних маршрутів, токенів Sanctum.
Інші джерела витоку через час:
- перевірка пароля лише для існуючих користувачів: для неіснуючого email сервер відповідає миттєво, для існуючого - після bcrypt (сотні мілісекунд). Це дає перелік облікових записів. Захист - виконувати хешування завжди;
- різна робота залежно від результату: відправка листа лише якщо обліковий запис існує, запит до бази лише для певних випадків;
- пошук у базі за секретом:
WHERE token = ?- час пошуку за індексом теоретично теж може залежати від значення. Тому надійніше шукати за ідентифікатором, а секрет порівнювати окремо черезhash_equals(так зроблено в Sanctum: токен має виглядid|секрет).
Наскільки це реально в інтернеті: мережевий шум у мілісекунди набагато більший за наносекундні різниці порівняння рядків, тож атака на сирі рядкові порівняння через інтернет складна. Але з тисячами вимірювань і статистикою вона можлива, а в межах одного хмарного регіону шум значно менший. Порівняння в постійному часі коштує нічого - немає причин ризикувати.
Не лише рядки: криптографічні реалізації мають бути «constant-time» загалом - тому використовують перевірені бібліотеки (sodium_*, openssl_*), а не власні реалізації алгоритмів.
«Увійти через Google/GitHub» здається простим, бо бібліотека (у Laravel - Socialite) бере на себе протокол. Але кілька помилок у логіці застосунку роблять його небезпечним.
1. Об'єднання облікових записів за email без перевірки.
$user = User::firstOrCreate(['email' => $socialUser->getEmail()], [...]);
Auth::login($user);
Якщо провайдер дозволяє вказати неперевірений email, зловмисник створює там обліковий запис з email жертви - і входить у її обліковий запис на вашому сайті. Правила:
- зв'язувати лише з підтвердженим у провайдера email (наприклад,
email_verifiedв OpenID Connect); - зберігати ідентифікатор провайдера (
provider+provider_id), а не лише email, і шукати насамперед за ним; - прив'язку соцмережі до існуючого облікового запису робити з облікового запису (користувач уже увійшов паролем) чи з підтвердженням поштою.
2. Відсутність перевірки state. Параметр state пов'язує відповідь провайдера із сесією користувача, що почав вхід. Без нього можлива CSRF-атака на вхід: жертву непомітно входять в обліковий запис зловмисника (і вона, наприклад, зберігає туди свої дані). Socialite перевіряє state за замовчуванням; stateless() вимикає перевірку - лише для API з PKCE.
3. Відкрите перенаправлення. Параметр «куди повернутися після входу» (?redirect=) без перевірки - фішинг на вашому домені. Дозволений лише відносний шлях свого сайту.
4. Неправильні redirect URI в налаштуваннях провайдера. Шаблони (https://*.example.com/*) чи зайві адреси дозволяють перехопити код авторизації. Точні адреси, лише HTTPS.
5. Довіра до даних профілю. Ім'я, аватар, email від провайдера - звичайні дані користувача: їх треба валідувати й екранувати.
6. Втрата доступу при видаленні облікового запису провайдера. Користувач без пароля, що втратив Google-акаунт, втрачає доступ - потрібні альтернативні способи входу.
7. Зайві права (scopes). Просити лише потрібне (openid email profile), а не доступ до пошти чи диска «на майбутнє».
8. Токени провайдера. Якщо застосунку справді потрібні токени доступу до API провайдера - зберігати їх зашифрованими (encrypted cast) і оновлювати refresh-токенами.
Для співбесіди: найважливіше - пункт 1. Захоплення облікових записів через неперевірений email - найпоширеніша реальна вразливість соціального входу.
Для безпеки потрібна не просто «випадковість», а непередбачуваність: знаючи попередні значення, неможливо вгадати наступні.
Некриптографічні генератори - mt_rand(), rand(), uniqid(), lcg_value(), Math.random() у JavaScript:
- побудовані для швидкості й рівномірного розподілу, а не для безпеки;
- внутрішній стан відновлюється з кількох виходів - для Mersenne Twister (
mt_rand) відомі інструменти, що за кількома значеннями обчислюють зерно й передбачають усі наступні; uniqid()- це просто поточний час у мікросекундах: передбачуваний.
Криптографічно стійкі генератори (CSPRNG) - з джерела ентропії операційної системи:
random_bytes(32); // 32 випадкові байти
bin2hex(random_bytes(32)); // 64 hex-символи
random_int(100000, 999999); // число в діапазоні - для кодів підтвердження
Str::random(40); // Laravel: на основі random_bytes
Str::password(16); // Laravel: пароль з різними класами символів
PHP 8.2 також має ООП-API Random\Randomizer з рушієм Random\Engine\Secure (за замовчуванням).
Де потрібен CSPRNG: токени сесій і API, токени скидання пароля й підтвердження email, коди 2FA й одноразові паролі, CSRF-токени, ключі шифрування, солі (зазвичай генерує password_hash), ідентифікатори, що служать «секретом» (неперебірні посилання на документи).
Розмір має значення: 128 біт ентропії (16 байтів) - мінімум для токенів, що мають бути неперебірними; 256 біт - з запасом. Шестизначний код - лише ~20 біт, тож його захищає не випадковість, а обмеження спроб і короткий термін дії.
Пастки:
md5(time()),sha1(uniqid()),md5(rand())- хешування не додає ентропії: передбачуване значення лишається передбачуваним;- зменшення діапазону через
%(random_bytes+% 10) дає нерівномірний розподіл - для чисел використовуватиrandom_int; - UUID як секрет: UUIDv4 має 122 випадкові біти і для більшості цілей годиться, якщо генерується CSPRNG. Але UUIDv7 (Laravel
HasUuids,Str::uuid7()) містить мітку часу і менше випадкових бітів - як ідентифікатор він добрий, а як секретний токен - ні; mt_srand()з фіксованим зерном у коді безпеки - повна передбачуваність.
У JavaScript для безпеки - crypto.getRandomValues() і crypto.randomUUID(), а не Math.random().