Блог ·
Таємниця, що подорожує лише раз
Перевірка спостереження потребує пароля на тому кінці. Ось як він дістається однієї сонди, для однієї спроби, ніколи не потрапляючи в журнал.
Автор: Provibr
Деякі перевірки спостереження потребують облікових даних. Не можна спитати сервер PostgreSQL, чи він здоровий, не увійшовши до нього, а примірник Redis із паролем не відповідає нічого корисного, доки ви його не надішлете. Отже, правило спостереження мусить донести таємницю від нас до тієї сонди чи того агента, який випадково візьме наступну спробу.
Дешевим варіантом цього є стовпець. Покласти пароль у правило, прочитати його під час надсилання, надіслати разом. Той варіант хибний у спосіб, що стає видимим лише через тринадцять місяців, і ось форма, яку ми збудували натомість.
Два шляхи, що вже існували, і чому жоден не годиться
Команда спостереження дістається джерела через два місця, і обидва непридатні.
Таблиця ревізій правила є незмінною за задумом, що цілком правильно для ревізії й цілком неправильно для облікових даних: ніхто не може змінити пароль, який живе в рядку, що не може змінитися без створення нової ревізії кожного правила, яке його вживало.
Рядок команди є гіршим, бо це рядок перевірки, і дорогою назовні він проходить через шину повідомлень. Наші власні проєктні нотатки вже рік кажуть, що рядок перевірки команди містить назви полів і ніколи значень. Пароль, який туди потрапить один раз, лишається там на весь строк зберігання, читний для кожного з доступом до бази даних і скопійований у резервну копію, що робилася тієї ночі.
Є ще й тимчасовий бічний канал саме для таких випадків, ужитий тоді, коли хтось замовляє машину і кореневий пароль має дістатися агента, не будучи збереженим. Він теж тут не пасує. Той канал існує для таємниць, які творець команди вже знає, а тут творцем є планувальник, що працює на хвилини раніше й ще не знає, яке джерело візьме роботу.
Тому чотири протокольні повідомлення, додані цього раунду, не мають ані поля користувача, ані поля пароля. Річ не в тім, що поля не вживаються: їх немає.
Запечатано в мить надсилання
Конверт створюють у сесійному робітнику, безпосередньо перед побудовою повідомлення, один раз на спробу. Це не охайність. Це єдина точка системи, де водночас відомі всі чотири речі, які він має привʼязати: яке джерело сидить на другому кінці цієї сесії, котра це спроба, до якої ревізії правила вона належить і коли вона спливає.
Наслідком є те, що він не може бути ніде інде, і це перевіряється, а не проголошується. Тест підсаджує відомий пароль, проходить справжнім шляхом і шукає його в навантаженні команди, у рядках подій, у збереженому налаштуванні перевірки, у шині повідомлень, у сирих протокольних байтах як у UTF-8, так і в base64, та в журналі шлюзу. Він вимагає нуль збігів у всіх шести. Перелік тверджень про те, куди таємниця не потрапляє, не вартий нічого; пошук вартий чогось.
Відкритий текст існує впродовж одного виразу. Одна функція його творить, наступна споживає, і потім посилання вже немає. JavaScript не вміє витирати буфер, тож єдиним доступним є ніколи його не тримати. З іншого боку, у Rust, він потрапляє в тип, який сам себе обнуляє під час звільнення.
Побудова
Ефемерна пара ключів X25519 на кожну спробу, обмін Дифі-Гелмана з відкритим ключем рівно одного джерела, HKDF-SHA256 для виведення ключа шифрування та AES-256-GCM над відкритим текстом. Дротом ідуть ефемерний відкритий ключ, разове число, шифротекст і мітка.
Обидва відкриті ключі потрапляють у сіль HKDF, щоб виведений ключ був привʼязаний саме до тієї пари, а не до спільної таємниці, яка в принципі могла б виникнути з іншої пари. Та сама думка, що й план ключів у звичній гібридній схемі шифрування, зі спрощенням, чесним тут: на кожну спробу є свіжий ефемерний ключ, тож жоден ключ не вживається двічі.
Чотири поля потрапляють у додаткові автентифіковані дані:
| Поле | Що воно спиняє |
|---|---|
| Ідентифікатор спроби | Те саме джерело, що повторно вживає конверт для пізнішого вимірювання |
| Ідентифікатор ревізії | Конверт, підкладений під іншу перевірку |
| Термін | Конверт із перехопленого трафіку чи дампа памʼяті, придатний пізніше |
| Призначення | Облікові дані PostgreSQL, згодовані перевірці Redis |
Ідентичності джерела в тому переліку немає, бо вона не потрібна: запечатування йде до одного відкритого ключа, тож інше джерело його взагалі не відкриє.
Префікси довжини, бо роздільник неоднозначний
Додаткові дані будують із довжиною кожного поля попереду, а не з роздільником між полями. Це читається як дрібʼязковість, доки ви не випишете інший варіант.
За форми a:b пара ("x", "y:z") і пара ("x:y", "z") дають однакові байти. Тоді звʼязок, який ці дані мають установити, більше не розрізняє двох різних станів, і той, хто керує частиною одного поля, може посунути межу. Чотири поля з чотирма байтами довжини попереду такої вади не мають. Обидва боки будують їх байт за байтом однаково, зі спільним тестовим вектором, тож розбіжність є червоним тестом, а не конвертом, якого ніхто вже не відкриє.
Чому не той шифр, який ми вже вживаємо
Сховище облікових даних агента вживає XChaCha20-Poly1305, і та сама побудова тут дала б одну відповідь на питання, як ми шифруємо таємницю, замість двох. Ми виміряли, і так не вийде:
node v24.20.0 crypto.getCiphers() містить "chacha20-poly1305"
bun 1.4.0 crypto.getCiphers() дає 28 назв, жодної chacha
createCipheriv("chacha20-poly1305", ...) -> "Unknown cipher"Продакшн працює на Node, тож там воно працювало б. Але тестовим верстатом цього сховища коду є bun, а автентифіковане шифрування, якого верстат не може виконати, є шифруванням, чий шлях туди й назад ніколи не доводиться. Єдиний контроль над конвертом жив би тоді в пропущеному тесті, а це саме той присмак порожньо-зеленого вимірювання, якого ми докладаємо чимало зусиль уникати.
Іншим варіантом було дописати відсутню частину вручну. Це справді зробили, воно працювало проти опублікованого тестового вектора, а потім його прибрали. Криптографічний код, який існує лише для обходу тестового верстата, не належить сховищу продукту.
Отже AES-256-GCM, те саме автентифіковане шифрування, яке вже захищає цю таємницю в спокої, наявне в обох середовищах виконання і вже присутнє в дереві залежностей агента. Разове число на 96 бітів, через яке зазвичай беруть XChaCha, тут не є завадою, бо сенс XChaCha полягає в тому, щоб такі числа можна було брати навмання під довговічним ключем. Довговічного ключа немає. Кожен конверт має свіжий ефемерний, тож кожен ключ служить рівно раз, і повторене число не було б другим повідомленням під тим самим ключем.
Де джерело тримає свою половину
Закритий ключ лежить поруч з ідентичністю агента чи сонди, у файлі з тридцяти двох сирих байтів із режимом 0600, поруч із закритим ключем клієнтського сертифіката. Не у сховищі облікових даних, і три причини варті запису, бо саме вони й були справжнім рішенням.
Відкликання витирає його задарма: відкликання вже вилучає той каталог, а ключ, покладений деінде, потребував би власного правила прибирання, яке перший раунд, що його забуде, лишить на відкликаному вузлі. Він належить одному записуванню, а платформа зберігає відкриту половину поруч із сертифікатом, тож нове записування має дати нову пару, що й відбувається, бо каталог тоді порожній. І сховище доступне не завжди: воно відмикається матеріалом із файла ліцензії, а сонда спостереження ліцензії не має і сховища теж. Класти ключ туди означало б другий механізм для сонд, і тоді на одне питання було б дві відповіді.
Його навмисно не тримають і в памʼяті, хоч це й спокусливо для сонди, яка нічого не зберігає. Платформа зберігає відкриту половину, тож після перезапуску вона більше не збігалася б, і кожен конверт лишався б закритим, доки оператор не запише сонду наново. Точка спостереження, яка після перезапуску мовчки припиняє робити автентифіковані перевірки, є саме тим різновидом збою, якого ніхто не помічає.
Ніколи запасного шляху
Якщо джерело не має ключа, перевірка не виконується. Вона повідомляє помилку налаштування, яка називає посередника недоступним, і це вся поведінка. Немає ані відступу до відкритого тексту, ані, що важливіше, оманливого стану недоступності для цілі, бо перевірка, якої не можна виміряти, ніколи не повинна виглядати як зламана послуга. Це тривога, що о третій ночі відправляє когось не до тієї машини.
Що ми записали б
- Рядок перевірки не є каналом доставки. Що там записано, лежить увесь строк зберігання, у кожній резервній копії.
- Не додавайте поле. Протокольне повідомлення без поля пароля не може випустити його жодним шляхом коду, який хтось забув переглянути.
- Печатайте там, де привʼязки відомі. Якщо творець завдання ще не знає, хто його виконає, таємниця не може виникнути разом із завданням.
- Доводьте відсутність пошуком, а не твердженням. Підсадіть значення й полюйте на нього в кожній серіалізації, разом із base64.
- Ставте префікси довжини на свої автентифіковані дані. Роздільник змушує дві різні пари давати ті самі байти.
- Обирайте примітив, який ваш тестовий верстат може виконати. Сильніша побудова, яку ніколи не вправляють, на практиці слабша за добру, яку вправляють.
- Ключ, який є ідентичністю, належить ідентичності. Тоді відкликання його прибирає, а нове записування замінює, без правила, яке хтось мусить памʼятати.
Для ясності щодо того, чим це є: спостереження лежить за перемикачем розгортання, вимкненим за замовчуванням, тож нічого з цього сьогодні не продається. Дві речі лишаються недоведеними. Обмін автентифікації на основі пароля виміряно лише проти еталонної реалізації протокольної бібліотеки, а не проти справжнього сервера. І двійковий файл сонди мовою Rust цього раунду не проганявся дротом.
Із платформної роботи Provibr. Чому сонда вважає ненадійним кожне отримане повідомлення, описано в сонді, яку ні до чого не вмовиш, а чому облікові дані власних панелей клієнта до нас узагалі не доходять, описано в де мають жити облікові дані панелі.