Як сервер може зберігати та синхронізувати наші секрети, не маючи ключів для їх читання? Ми простежимо, як Confidex створює свої ключі, захищає їх у замку та використовує їх для шифрування даних і перевірки змін.
Замок
Лише Обраний може відчинити ці двері
Матриця перезавантаження
Уявіть, що ми створюємо віртуальний vault для зберігання секретних даних. Він повинен зберігати ці дані на ноутбуці після закриття програми та дозволити нам відкрити її на іншому пристрої. Для сховища знадобляться власні ключі. Ми повинні зберігати їх так, щоб копіювання файлів не дозволяло стороннім особам прочитати чи змінити їхній вміст.
lock захищає ключі: невеликий зашифрований файл. Щоб відкрити його, потрібен головний пароль і секретний код secret_key; ні в самому файлі. Ми могли б надрукувати весь замок у ранковій газеті. Читачі отримають копію, але її публікація не розкриє особистих ключів.
Припустімо, стороння особа отримує цей файл і навіть дізнається головний пароль. Тепер вони можуть повторювати кожне обчислення, для якого потрібен лише пароль, але ці обчислення не дадуть ключа, який відкриває замок: secret_key також є введенням. Файл не містить окремої перевірки лише пароля. Щоб його відкрити, усе одно потрібен невідомий секретний код.
Цей код походить із криптографічно випадкових даних; людина це не вигадує. Поки secret_key невідомий, навіть правильно вгаданий головний пароль не відкриває замок: зловмисникові все одно бракує випадкового секрету. Таким чином, сам по собі слабкий пароль не перетворює копію сервера на практичну атаку за словником.
Якщо зловмисник також отримує secret_key, він може перевірити вгадування головного пароля в автономному режимі. Це дорого, але можливі труднощі залежать від надійності пароля.
Ключі
Панове, що роблять ключі? Ключі... розблокувати речі?
Пірати Карибського моря - Скриня мерця
Програма отримує 32 випадкових байти від криптографічного генератора операційної системи та готує їх для використання як ключ. Ці підготовлені байти є приватним головним ключем master_key.
Програма завжди отримує ті самі ключі з master_key: повторення тих самих обчислень для тих самих байтів дає той самий результат. Тому ноутбук і телефон можуть відновити ідентичні ключі. Щоб отримати master_key на новому пристрої, програма повинна спочатку відкрити замок за допомогою головного пароля та secret_key.
Перша пара: шифрування
Я Майстер ключів.
Мисливці за привидами
Щоб зберігати зашифровані дані, програма обчислює відкритий ключ із master_key: secret_key. Він може шифрувати дані для сховища, тоді як для дешифрування потрібен master_key.
«Публічний» описує роль цього ключа в алгоритмі, а не зобов’язання надавати його всім. У Confidex він залишається з користувачем. Це також другий секрет, необхідний для відкриття замка, тому він залишається локальним і не надсилається через мережу. Сам по собі secret_key дозволяє комусь шифрувати дані для сховища, але не розшифровувати їх або підписувати зміни від свого імені.
X25519 – це алгоритм, який приймає master_key і обчислює secret_key. Відновити master_key з secret_key практично неможливо. Класична атака на уявний суперкомп’ютер, який виконує мільярд мільярдів кривих операцій за секунду, триватиме в середньому близько 2,3 трильйона років (оцінка SafeCurves).
Друга пара: підписи
Єдине, що оригінально, це підпис
Злови мене, якщо зможеш
Перша пара допомагає приховати вміст. Тепер нам потрібен спосіб підтвердити, що власник сховища дозволив зміни. Сервер також повинен мати можливість перевіряти підписи, тому нам потрібно надіслати йому відкритий ключ перевірки. Загальнодоступною частиною першої пари є secret_key, яку сервер не повинен отримати. Тому ми створюємо другу постійну пару ключів для підписів.
Замість того, щоб зберігати інший незалежний секрет, ми отримуємо приватний ключ підпису з master_key і називаємо його signing_key. Програма використовує його для підпису даних і блокування. Інші пристрої та сервер використовують його публічну частину для перевірки підписів без читання вмісту.
Ми можемо відновити цю пару з того самого master_key. Програма отримує з нього початкове значення: 32 байти, які однозначно визначають ключ підпису.
Щоб отримати різні ключі для різних завдань, ми включаємо в обчислення фіксовану мітку: тут confidex/vault/ed25519/v1. Той самий master_key і та сама мітка завжди створюють той самий ключ.
Головний пароль не ввів ці обчислення. Обидві пари залежать від випадкових байтів master_key, а не від рядка, придуманого людиною. Це розрізнення матиме значення пізніше: пароль захищатиме доступ до ключів, які вже існують, тому ми можемо змінити його, не змінюючи самі ключі.
Виведення використовує функцію виведення ключа на основі хешу: HKDF, HMAC-based Extract-and-Expand Key Derivation Function. Confidex використовує його з SHA-256, отже назва HKDF-SHA256. Отримані 32 байти є seed для пари Ed25519. Публічна частина signing_key не розкриває secret_key, тому ми можемо надіслати її на сервер для перевірки підпису.
Що таке файл блокування і що він робить?
Відкрий двері відсіку для контейнерів, ХАЛ.
Космічна одіссея
Вибач, Дейве. Боюся, я не зможу цього зробити.
Замок зберігає зашифрований master_key. Щоб відкрити його, потрібен головний пароль і secret_key. Загальнодоступний заголовок містить версію формату, ідентифікатор сховища, публічну частину signing_key, сіль і параметри обробки пароля. Версія внутрішнього формату зашифрована разом із master_key.
Під час створення блокування програма вже має master_key, тому вона обчислює secret_key перед шифруванням. Відкриваючи його, користувач окремо вказує secret_key: його немає в публічній копії замка.
Після розшифровки програма обчислює публічну частину master_key за допомогою X25519 і вимагає, щоб вона відповідала secret_key. Потім він відновлює signing_key і порівнює його публічну частину з ключем у заголовку. Таким чином замок перевіряє сам себе.
Щоб уповільнити вгадування головного пароля, кожна спроба потребує часу та пам’яті. Confidex використовує Argon2id із 64 МБ пам’яті та трьома проходами. Для обробки паролів також використовується випадкова сіль. Сіль не є таємницею: вона змушує той самий пароль давати різні результати в різних замках.
Якщо secret_key вже відомий, довжина пароля різко змінює масштаб атаки. Існує близько 12 мільйонів комбінацій з 5 випадкових малих латинських літер, близько 8 мільярдів з 7 і близько 95 квадрильйонів з 12. Перехід від 7 до 12 літер збільшує простір пошуку майже в 12 мільйонів разів. Якщо пошук 7 літер займав у середньому один день на певній машині, то 5 знадобилося б приблизно дві хвилини, а 12 – приблизно 33 000 років.
Щоб отримати один ключ із результату Argon2id і secret_key, ми використовуємо функцію HKDF-SHA256, представлену раніше, з міткою confidex/lock/wrap/v1. Результатом є wrapping_key, ключ, який шифрує замок.
Ми повинні приховати master_key і захистити його від підробки. XChaCha20-Poly1305 шифрує його за допомогою wrapping_key і додає тег автентифікації. Тег прив’язує зашифрований текст до загальнодоступного заголовка: неправильний пароль або зміна захищених байтів призводить до помилки відкриття. Тег і випадкове одноразове значення nonce зберігаються разом із зашифрованим текстом.
Кожного разу, коли блокування створюється або шифрується знову, програма отримує нову соль і nonce. Зашифрований вміст включає один байт версії та 32 байти master_key; 24-байтовий nonce і 16-байтовий тег зберігаються поруч з ними. Документація алгоритму описує конструкцію.
Невідповідність між відновленими ключами може бути результатом навмисно неправильного блокування або помилки в програмі, яка його створила. Випадкове пошкодження байтів зазвичай виявляється раніше, коли перевіряється тег.
Сервер не має секретів, необхідних для перевірки цього тегу. Щоб передати блокування, програма додає підпис, зроблений за допомогою signing_key. Він охоплює весь готовий файл, включаючи публічний заголовок і зашифрований вміст. Сервер перевіряє підпис і під час першого завантаження закріплює повну загальнодоступну частину signing_key і внутрішній ідентифікатор сховища. Він перевіряє подальші зміни за цим ключем. Блокування та зашифровані дані підписані тим самим signing_key, відновленим із master_key.
Сховище
Тепер ми можемо опублікувати замок. Лише той, хто знає secret_key і master password, може прочитати master_key, у той час як будь-хто може взяти відкритий ключ підпису з файлу блокування та перевірити підписані дані, не бачачи ні вмісту блокування, ні даних.
Розглянемо пароль, SuperPa55worD. Наразі ми хочемо лише зберегти його: після закриття програми ми зможемо надіслати файл блокування та зашифрований пароль куди завгодно, щоб ніхто не побачив SuperPa55worD, дозволяючи їм підтверджувати наше авторство. Шифрування має бути оборотним, якщо ми маємо secret_key і master password. Симетричний шифр може шифрувати і дешифрувати за допомогою одного ключа. Оскільки ми не зберігаємо цей ключ, ми повинні мати можливість відновити його за допомогою замка. Ми назвемо це message_key.
Ми отримаємо окремий message_key для кожного нового шифрування. Нам ще потрібно організувати його відновлення: якщо залишити ключ відкритим поруч із зашифрованим текстом, будь-хто з копією зможе прочитати все.
Зі свіжих випадкових байтів програма створює тимчасову пару X25519 лише для цього шифрування: record_key. Потім він бере приватну частину record_key і постійний відкритий ключ сховища, secret_key, і обчислює спільний секрет. Окрема функція отримує message_key з цього секрету; цей ключ шифрує наш пароль.
Під час читання програма йде іншим шляхом. Він використовує master_key і публічну частину record_key. Ці два ключі створюють один і той же спільний секрет. Математично обидва маршрути множать ту саму початкову точку кривої на два приватні числа в іншому порядку. Порядок не змінює результат. Тому ми можемо відновити ключ дешифрування, не зберігаючи закриту частину record_key.
Отримати цей секрет із двох відкритих ключів практично неможливо: будь-який шлях потребує відповідного закритого ключа. Публічні точки не дозволяють нам ефективно відновлювати приватні номери, які їх створили. Цей математичний трюк лежить в основі безпеки сховища. Але це не заважає комусь подарувати нам інше сховище з нашим замком, прикріпленим до нього.
Тепер ми можемо зберігати зашифрований текст разом із загальнодоступною частиною record_key. Приватна частина record_key і використана message_key більше не потрібні для подальшого читання. Пізніше програма відновлює ключ дешифрування з master_key і збережену публічну частину record_key. Таким чином, один постійний секрет сховища може розшифрувати багато окремо зашифрованих значень, кожне зі своїм власним message_key, відновленим за потреби.
Приховати текст недостатньо: ми також повинні виявити підробку під час його читання. Таким чином, шифрування додає тег автентифікації, коротке значення, яке перевіряється разом із зашифрованими даними. Якщо перевірка не вдається, програма відхиляє результат. Він не повинен відображати випадковий або змінений текст як успішно розшифрований пароль.
Ми описали конструкцію, в якій асиметрична пара допомагає встановити спільний секрет, а message_key шифрує вміст. Це називається Hybrid Public Key Encryption або HPKE і вказується в RFC 9180. Confidex використовує X25519 для узгодження секрету, HKDF-SHA256 для отримання ключів і ChaCha20-Poly1305 для шифрування з перевіркою цілісності. Тут HKDF бере спільний секрет, погоджений за допомогою record_key і пари master_key; це окрема робота від отримання Ed25519 seed. RFC 5869 описує HKDF.
Для наступного шифрування програма створює новий контекст record_key і новий контекст HPKE. Повторне використання комбінації message_key і nonce порушує захист шифру. У Confidex ключ і nonce виводяться всередині HPKE, використовуються для одного повідомлення, а потім контекст відкидається.
Сам ChaCha20 використовує 256-бітний message_key. Щоб отримати уявлення про пряму атаку, уявіть вигаданий суперкомп’ютер, який тестує мільярд мільярдів кандидатів на секунду. Пошук 2²⁵⁶ можливих ключів займе приблизно 1,8 × 10⁵¹ років. RFC 8439 описує алгоритм і його аналіз.
Запис
Ми щасливі?
Кримінальне чтиво
Ми зберегли пароль, але для входу на веб-сайт зазвичай також потрібні ім’я користувача та адреса. Іноді нам потрібна записка біля них. Якщо ми зберігаємо ці значення окремо, програма має якимось чином пам’ятати, що вони належать разом. Давайте покладемо ці «папірці» в конверт. Ми об’єднуємо поля в одну колекцію та називаємо її record.
Запис може містити будь-яку комбінацію полів. Програма збирає їх в один пакет, стискає його та шифрує за допомогою конструкції, яку ми щойно описали. Значення полів, імена, типи та теги захищені. Зміна адреси веб-сайту, навіть із збереженням того самого пароля, створює нову версію всього запису, зашифрованого новим record_key.
Хеш-ланцюжок
ЧИ ПОВИНЕН ВАШ ПРОЕКТ ВИКОРИСТОВУВАТИ БЛОКЧЕЙН? → НІ
XKCD 2267
Хтось, хто знає secret_key, може підготувати зашифрований запис для нашого сховища. Щоб не вважати його нашим, програма підписує кожну версію запису signing_key. Без цього ключа стороння особа не зможе створити версію з підписом, яку прийме сервер та інші пристрої.
Зашифрована версія потребує деякої супровідної інформації: до якого сховища та запису вона належить, коли вона змінилася та які версії їй передують. Це формує публічний заголовок. Він не містить пароля, але все одно потребує захисту: інакше хтось може змінити право власності та зв’язки об’єктів, не торкаючись зашифрованого тексту. Програма підписує точні байти заголовка разом із зашифрованим текстом. Зміна підписаних даних робить підпис недійсним.
Сервер нагадує склад запечатаних посилок. Він бачить заголовок, зашифрований вміст і підпис, але не всередині пароля, і знає, що одержувач існує в місті. Він перевіряє підпис із закріпленою загальнодоступною частиною signing_key і вирішує, чи приймати нову версію. Для цього не потрібен ні головний пароль, ні secret_key, ні закритий ключ. Під час читання клієнт перевіряє той самий підпис, а потім знову перевіряє зв’язок між вмістом і заголовком під час дешифрування, використовуючи тег автентифікації HPKE.
Підпис також захищає зв’язки між версіями. Хеш SHA-256 точного заголовка та байтів зашифрованого тексту стає ідентифікатором версії. Заголовок наступної версії містить ідентифікатор попередньої версії. Коли ми змінюємо пароль веб-сайту, програма створює та підписує новий об’єкт із цим посиланням. Старий об'єкт і його підпис залишаються незмінними. Тому заміна попередника зі збереженням підпису нової версії не вдасться. Якщо два пристрої вносять незалежні зміни, обидві гілки виживають; об’єднана версія може стосуватися обох.
Пам'ятаєте блокчейн? Ось воно :-).
Загальнодоступний заголовок дозволяє перевіряти посилання без розшифровки, але деяка інформація залишається видимою. Сервер знає ідентифікатори, посилання на версії, час зміни, наданий клієнтом, кількість полів і закодований розмір кожного значення поля. Шифрування приховує вміст запису, а не цю структурну інформацію.
Синхронізація
«Не витік даних, а безкоштовне розповсюджене резервне копіювання».
Інтернет мем.
Для роботи на кількох пристроях нам потрібно перенести блокування та записувати історію між ними. Формат об'єкта не змінюється: ми передаємо ті самі зашифровані байти та підписи, які зберігаються локально.
Для передачі вручну програма створює архів ZIP, що містить блокування, усі локально збережені зашифровані версії записів і підписаний список файлів. Ні пароль, ні secret_key не включені. На іншому пристрої програма спочатку відкриває замок, потім перевіряє підписи, розшифровує записи та відновлює локальну копію сховища. Імпорт призначений для пристрою, де це сховище ще не існує; він не об’єднує історію з існуючою копією.
Сервер синхронізації обмінюється змінами між робочими копіями. Під час першого завантаження він отримує підписане блокування, перевіряє підпис і обчислює адресу публічного сховища з загальнодоступної частини signing_key. Потім він запам’ятовує цей ключ та ідентифікатор сховища із заголовка: це реєструє сховище на сервері. Подальші зміни повинні відповідати цьому ідентифікатору та мати підпис із того самого ключа.
Для нового пристрою потрібна адреса сховища та secret_key, тому програма поєднує їх у код передачі з контрольною сумою. Користувач передає цей код на пристрій і окремо вводить головний пароль; на сервер надсилається лише публічна адреса.
Після відкриття завантаженого блокування програма порівнює загальнодоступні частини відновлених ключів із secret_key і заголовком і перевіряє обчислену адресу на відповідність коду передачі. Потім він зберігає перевірену загальнодоступну частину signing_key для перевірки записів.
Під час синхронізації програма отримує відсутні версії та завантажує свої зміни. Сервер перевіряє підписи та посилання на попередні версії, зберігає прийнятні об’єкти та передає їх на інші пристрої. Перевірка отриманих даних, їх розшифровка та вирішення конфліктів — усе це відбувається на пристроях користувача.
Під час реєстрації не потрібно вводити ім’я, адресу електронної пошти чи окремий пароль. Сервер бачить публічну адресу, ключ перевірки підпису, публічні заголовки, розміри об’єктів і посилання на версії. Він також бачить IP-адреси та час запиту. Головний пароль, secret_key, особисті ключі та вміст відкритого тексту залишаються на пристроях користувача. Кожен, хто знає публічну адресу, може завантажити зашифровані об’єкти, але для їх читання потрібні секрети користувача.
Ці перевірки виявляють підроблені об’єкти, але не доводять, що сервер показав повну історію та свій останній стан. Він може приховати гілку, яку клієнт ніколи не бачив, або повернути узгоджений старий набір даних. Підпис підтверджує автентичність окремого об’єкта, а не повноту відповіді сервера. Користувач може помітити відсутні зміни на іншому пристрої, але послідовна стара історія пройде перевірку hash-chain.
Зміна головного пароля
Отже, ваша святість змінить її пароль?
Хакери (1995)
Зміна головного пароля повторно шифрує лише замок; master_key всередині залишається незмінним. Тому записи, їхні підписи, secret_key і адреса сховища залишаються незмінними. Новий замок підписується тим самим signing_key.
Стара копія замка все ще відкривається зі старим паролем і тим самим secret_key; зміна пароля не скасовує ключі, які вже були розкриті.
Повна картина
Блокування захищає master_key, message_key шифрує записи, а signing_key створює підписи. Архіви ZIP і сервер отримують зашифровані об'єкти з підписами.