Як я створив анонімний чат з наскрізним шифрування на Blazor та з якими викликами зіткнувся

💡 Усі статті, обговорення, новини про продукти — в одному місці. Приєднуйтесь до Product спільноти!

Привіт, мене звати Роман, я Fullstack розробник. Основний мій стек .net, проте за час своєї кар’єри працював з різними технологіями та підходами.

У цій статті хочу розповісти свою коротку історію, як цікавість переростає в ідею, ідея в прототип, а прототип в повноцінний проєкт з активною базою користувачів.

З чого все почалося

Як часто буває, ти не шукаєш ідею, вона знаходить тебе сама, головне бути відкритим до неї в цей момент. Я працю FullStack розробником більше 4 років, і в процесі роботи доводиться стикатись з багатьма рішеннями та підходами, щоб реалізувати той чи інший функціонал. Часто ти навіть не сильно замислюєшся, як саме працює конкретний алгоритм, готовий пакет, або різні бібліотеки, чи от як в моєму випадку шифрування.Ти просто знаєш, що маєш надати, і що отримати, і рухаєшся далі.

Проте цього разу, пишучи черговий сервіс для шифрування даних, мені стало цікаво дослідити детальніше, що саме відбувається під час шифрування і розшифрування, які є особливості, як працюють алгоритми, які математичні моделі використовуються, як можна підняти безпеку. А найголовніше, чи можна створити свій власний метод шифрування, і на скільки надійним буде така затія. Мабуть багато хто в дитинстві переставляв букви місцями, щоб дорослі не могли прочитати, а потім пишався собою, що зумів передати приховані повідомлення. А це вже непогана база, яку ще в античні часи описав Полібій.

Спроби та помилки

Я почав більше цікавитись темою, ознайомився з «Трактатом про шифри» Леона-Баттіста Альберті, шукав інші матеріали, і головним питанням було — чи можна створити власний метод, і на скільки він буде надійним. Шукаючи відповіді на це питання, я дізнався чимало цікавого. До прикладу, що захищеність шифру не має базуватись на прихованні його алгоритму, чи захищеність даних не рівна складності алгоритму, що два однакові зашифровані значення не мають повторюватись, не повинні містити закономірностей. І чим більше я в це занурювався, я розумів, що сила шифрування не в його складності чи прихованості, а навпаки у відомості. Тобто, те що вже було винайдено, і протестовано часом та досвідом, набагато надійніше, ніж те що буде виглядати складним та новим, але не підтвердило свою надійність.

І тоді я змінив свій напрямок досліджень, і зосередився на існуючих підходах. Я дізнався про протокол Діффі-Гелмана на еліптичних кривих, та AES-256 GCM шифрування. І вирішив спробувати реалізувати найпростіше шифрування-розшифрування в звичайному консольному застосунку на .net.

Ідея була до банального проста: розділити застосунок умовно на дві частини, створити спільний ключ шифрування окремо в кожній з них, ввести текстове значення, зашифрувати його з допомогою першого ключа і розшифрувати з допомогою другого. І тут почалось найцікавіше, я зміг застосувати свій багаж недавно отриманих знань практично на такому нетривіальному для мене занятті.

Для початку нам треба незалежно створити спільний ключ, з допомогою якого можна буде шифрувати та розшифровувати дані, щоб максимально зберегти приватність і безпеку. Ключ за жодних обставин не має покидати своєї умовної частини застосунку.

Щоб досягти такого результату, можна використати (Elliptic Curve Diffie-Hellman) принцип Діффі-Гелмана, що дозволяє за значеннями координатів точок на еліптичних кривих отримати публічне значення. Беремо базову криву NIST P-256.

Цей алгоритм дозволяє нам згенерувати два ключі: приватний — випадкове число, та публічний — це точка на еліптичній кривій, обчислена з приватного ключа. Для цього нам чудово підходить бібліотека System.Security.Cryptography та статичні класи для роботи з кривими ECDiffieHellman.

var keysUserA = GenerateKeys();
var keysUserB = GenerateKeys();

public static (byte[] publicKey, byte[] privateKey) GenerateKeys()
{
    using var privateECDiffieHellman = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
    byte[] publicKey = privateECDiffieHellman.ExportSubjectPublicKeyInfo();
    byte[] privateKey = privateECDiffieHellman.ExportECPrivateKey();

    return (publicKey, privateKey);
}

Отримавши пари ключів, робимо обмін публічними ключами, що важливо, приватний ключ за жодних обставин не має покидати свої частини застосунку.

І використовуючи свій приватний ключ, і отриманий публічний, ми можемо створити спільний секретний ключ, який буде ідентичний в обох частинах застосунку.

byte[] sharedSecretA = GenerateSharedSecret(keysUserA.privateKey, keysUserB.publicKey);
byte[] sharedSecretB = GenerateSharedSecret(keysUserB.privateKey, keysUserA.publicKey);

public static byte[] GenerateSharedSecret(byte[] privateKey, byte[] publicKey)
{
    using var ecdPrivate = ECDiffieHellman.Create();
    ecdPrivate.ImportECPrivateKey(privateKey, out _);

    using var ecdPublic = ECDiffieHellman.Create();
    ecdPublic.ImportSubjectPublicKeyInfo(publicKey, out _);

    return ecdPrivate.DeriveKeyFromHash(ecdPublic.PublicKey, HashAlgorithmName.SHA256);
}

Тепер при наявності спільного секретного ключа, можемо виконати симетричне шифрування. Що якраз і передбачає використання одного ключа для шифрування і розшифрування.

Для цього використовую алгоритм AES в режимі GCM для забезпечення цілісності даних.

Як я вже згадував раніше, існують додаткові методи забезпечення безпеки шифрованих даних,такі як nonce — одноразовий ключ для розшифрування, tag — для гарантування що дані не підроблені.

І останній крок, це пакування байтів разом, для зручності через Buffer.BlockCopy.

var encrypted = Encrypt(messageRaw, sharedSecretA);
byte[] pack = PackData(encrypted.cipherText, encrypted.nonce, encrypted.tag);
string packBase64String = Convert.ToBase64String(pack);

public static (byte[] cipherText, byte[] nonce, byte[] tag) Encrypt(string plainText, byte[] key)
{
    byte[] nonce = RandomNumberGenerator.GetBytes(12);
    byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);

    byte[] cipherText = new byte[plainBytes.Length];
    byte[] tag = new byte[16];

    using var aes = new AesGcm(key, 16);
    aes.Encrypt(nonce, plainBytes, cipherText, tag);

    return (cipherText, nonce, tag);
}

Далі цей пакет передається в умовно іншу частину застосунку, де відбуваєтсья зворотній процес. Проте для дешифрування вже використовується секретний ключ, який згенерований там.

byte[] packBytes = Convert.FromBase64String(packBase64String);
var unpackData = UnpackData(packBytes);
string messageDecrypted = Decrypt(unpackData, sharedSecretB);

public static string Decrypt((byte[] cipherText, byte[] nonce, byte[] tag) data, byte[] key)
{
    byte[] decrypted = new byte[data.cipherText.Length];

    using var aes = new AesGcm(key, 16);
    aes.Decrypt(data.nonce, data.cipherText, data.tag, decrypted);

    return Encoding.UTF8.GetString(decrypted);
}

Тепер, коли всі кроки реалізовані, можна запускати застосунок вперше.

І це момент, який мене дуже вразив, я вже давно не отримував такої щирої радості від свого коду.

Я просто ввів випадкове слово і натиснув enter, програма зашифрувала його, показала мені шифр, потім передала в іншу частину застосунку, розпакувала, розшифрувала і показала мені те ж саме слово. І з одного боку ніби нічого особливого, але для мене це були щирі емоції задоволення, які я не отримую пишучи складні корпоративні сервіси і модулі.

Результат виконання програми

Я ще трохи погрався з кодом, порефакторив, погрупував, покращив де це можна було, і в мене з’явилося непереборне бажання десь це використати, і це той момент коли зародилась ідея проєкту.

Формування концепції

Я зразу розумів, що не хочу створити ще один месенджер в безкінечному списку однакових застосунків, я знав про існування Signal, який заявляється як еталон захищеного шифрування, та і про багато інших популярних месенджерів.

Проте я хотів зробити щось максимально просте, зручне, і швидке у використанні, щоб від відкриття і до використання проходило якомога менше кроків та кліків.

І я вивів для себе основні базові принципи, яким має слідувати мій проєкт:

  1. Основний фокус — це безпека та приватність.
  2. Універсальність та швидкість входу
  3. Зручність та базові функції.

Сформувавши основні принципи, постало питання, як краще це реалізувати.

Для початку потрібно було вибрати фреймворк, і я зупинився на Blazor. Я колись починав свою професійну кар’єру на ньому, і ще відтоді він мені як рідний.

Далі, для того, щоб дотримуватись першого базового принципу, всі дані мають бути виключно на пристрої користувача. Тому я обрав Blazor WebAssembly, який завантажує весь код в браузер на пристрій користувача, і працює лише там. Таким чином всі дані і ключі, які будуть згенеровані протягом сесії, будуть захищені і доступні лише на пристрої, для мене це ідеальний варіант.

Концептуальна діаграма

Я рухався далі, щоб максимально забезпечити приватність і простосту використання, я повністю відкинув ідею створення профілю чи логування, основна ідея полягала в наступному:

  • Користувач відкриває сайт
  • Для початку роботи просто достатньо ввести свій псевдонім, жодних даних, імен, номерів, емейлів чи реєстрації
  • Після чого генерується унікальний лінк для запрошення співрозмовника
  • Ділитись лінком можна будь-яким зручним методом
  • Коли співрозмовник по ньому переходить, він теж додає свій псевдонім і долучається до чату
  • В цей момент між вами і співрозмовником відкривається канал зв’язку,, відбувається обмін публічними ключами, після чого встановлюється захищене з’єднання і доступ в чат блокується ззовні, тобто повторний перехід за тим же посиланням більше не доступний.
  • І ви спілкуєтесь як у звичайному чаті, бачите відмітки прочитаного, набору тексту співрозмовником, та можете видаляти свої повідомлення.
  • І найголовніше, якщо ви чи співрозмовник залишаєте чат, через кнопку чи просто закривши вкладку, з’єднання розривається, чат закривається в обох користувачів, а всі дані очищуються і зникають назавжди. Нуль цифрового сліду, жодних збережень на сервері, все існувало в межах однієї сесії.

Ця концепція мене захопила, і я приступив до роботи

Реалізація, виклики та реліз

Я підійшов грунтовно до реалізації, і обміркував всі кроки: встановлення з’єднання і створення чату, видалення і очищення даних чату, розрив з’єднання, реконект, захист з’єднань, уникнення зовнішнього проникнення.

Як вже згадував раніше, для клієнтської частини використав Blazor WebAssembly.

Створив домашню сторінку з загальним описом функціоналу та кнопкою для створення чату. І безпосередньо сам чат, з усім базовим функціоналом.

Домашня сторінка EchoChat

Для додаткової безпеки додав відображення публічних ключів у форматі SHA-256, щоб їх можна було перевірити і уникнути підміни ключів.

Перевірка публічних ключів

Також додав сторінку з описом принципу роботи чату, і сторінку для відгуків

Сторінка - як це працює

І тут був перший серйозний виклик, вбудовані пакети System.Security.Cryptography та класи ECDiffieHellman не підтримуються браузером. Реалізований мною алгоритм шифрування в консольній аплікації, не працював в браузері. Досліджуючи тему, я зрозумів, що найкращим рішенням буде перейти від готових high-level API рішень, до низько рівневих математичних підходів, з допомогою бібліотеки Bouncy Castle. І це накладає більшу відповідальність і складність алгоритму, на відміну від готових рішень, потрібно самому розробити алгоритм, задати криву, формувати domain parameters, збирати AEAD. І це було ще більшим зануренням в криптографію саме з погляду програмування. В результаті я реалізував ідентичний механізм, але який вже успішно працював у браузері.

Приклад генерації пар ключів з допомогою Bouncy Castle.

public static (byte[] publicKey, byte[] privateKey) GenerateKeys()
{
    var curve = ECNamedCurveTable.GetByName("P-256");
    var domainParams = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());

    var generator = new ECKeyPairGenerator();
    generator.Init(new ECKeyGenerationParameters(domainParams, new SecureRandom()));

    var keyPair = generator.GenerateKeyPair();

    byte[] privateKey = ((ECPrivateKeyParameters)keyPair.Private).D.ToByteArrayUnsigned();
    byte[] publicKey = ((ECPublicKeyParameters)keyPair.Public).Q.GetEncoded(false);

    return (publicKey, privateKey);
}

Приклад генерації спільного секретного ключа з допомогою Bouncy Castle.

public static byte[] GenerateSharedSecret(byte[] privateKeyBytes, byte[] publicKeyBytes)
{
    var curve = ECNamedCurveTable.GetByName("P-256");
    var domainParams = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());

    var privateKey = new ECPrivateKeyParameters(
        new Org.BouncyCastle.Math.BigInteger(1, privateKeyBytes),
        domainParams);

    var publicKey = new ECPublicKeyParameters(
        curve.Curve.DecodePoint(publicKeyBytes),
        domainParams);

    var agreement = new ECDHBasicAgreement();
    agreement.Init(privateKey);

    var secret = agreement.CalculateAgreement(publicKey).ToByteArrayUnsigned();

    using var sha256 = SHA256.Create();

    byte[] context = Encoding.UTF8.GetBytes("EchoChat");
    byte[] combined = new byte[secret.Length + context.Length];

    Buffer.BlockCopy(secret, 0, combined, 0, secret.Length);
    Buffer.BlockCopy(context, 0, combined, secret.Length, context.Length);

    return sha256.ComputeHash(combined);
}

Наступний важливий крок, це реалізація з’єднання між клієнтами, яким чином зашифровані пакети будуть передаватись. Я вирішив використати SignalR і написати свій хаб для створення і роботи зі з’єднаннями. Для цього я створив окремий серверний проєкт на ASP.NET WEB API, який мав працювали лише як проміжна ланка між клієнтами, створювати і менеджерити з’єднання та передавати пакети.

Також, тут важливо наголосити, що в контексті безпеки і приватності, сервер працює лише як ретранслятор. Жодні дані, ключі, навіть зашифровані пакети не зберігається на сервері, він лише є проміжною ланкою що відповідає за моніторинг з’єднань і ретрансляцію зашифрованих пакетів. Важливо нагадати, що секретний ключ ніколи не покидає пристрій користувача, і без нього немає можливості розшифрувати захищений пакет.

public async Task SendEncryptedMessage(string chatId, string base64Pack)
{
    await Clients.OthersInGroup(chatId)
        .SendAsync(EventConstants.RECEIVE_MESSAGE, base64Pack);
}

Я зосередився на написанні просунутого хабу для роботи зі з’єднаннями.

А саме, забезпечив безпеку з’єднань, обмежив доступ лише 2 активним клієнтам. написав гнучку систему реконекту за допомогою реконект токена. Оскільки на телефоні, при згортанні браузера, операційна система одразу вбиває websocket, додав певний час на можливість реконекту, та автоматичне очищення на обох пристроях при невдалому реконекті, або несанкціонованому доступі.

public async Task JoinChat(
    string chatId, 
    string publicKey, 
    string alias, 
    string reconnectToken)
{
    if (!_chats.TryGetValue(chatId, out var connections))
    {
        await Clients.Caller.SendAsync(EventConstants.CONNECTION_ACCESS_DENIED);

        return;
    }

    lock (connections)
    {
        if (connections.Count >= 2)
        {
            Clients.Caller.SendAsync(EventConstants.CONNECTION_ACCESS_DENIED);

            return;
        }

        if (connections.Any(x => x.ConnectionId == Context.ConnectionId))
        {
            return;
        }

        connections.Add(new ConnectionModel
        {
            ConnectionId = Context.ConnectionId,
            PublicKey = publicKey,
            Alias = alias,
            CreatedAt = DateTime.UtcNow,
            ReconnectToken = reconnectToken
        });
    }

    await Groups.AddToGroupAsync(Context.ConnectionId, chatId);
    Context.Items["ChatId"] = chatId;

    var peer = connections.FirstOrDefault(x => x.ConnectionId != Context.ConnectionId);

    if (peer is not null)
    {
        await Clients.Caller.SendAsync(EventConstants.RECEIVE_PUBLIC_KEY, peer.PublicKey, peer.Alias);
        await Clients.Client(peer.ConnectionId!).SendAsync(EventConstants.RECEIVE_PUBLIC_KEY, publicKey, alias);

        await _statisticService.JoinSessionAsync(chatId);
    }
}

Мабуть тестування і доведення до ідеалу хабу зайняло в мене найбільше часу. Оскільки це один з найбільш важливих і вразливих місць системи, і він мав працювати відповідно.

В результаті всього вище описаного і зробленого, я отримав робочого клієнта, з досить приємним сучасним дизайном, захищений механізм симетричного наскрізного шифрування, та потужний хаб для менеджменту з’єднань.

Після чого був фінальний штрих цієї картини, я придбав домен і задеплоїв всі складові проєкту.

І тут стався другий щасливий момент за час розробки проєкту, коли я відкрив сайт, ввів перший псевдонім та створив захищене з’єднання, і все запрацювало як годиться. Я був неймовірно радий що реалізував це, і все працює як я планував.

Наступним кроком було тестування всіх можливих варіантів. Я використовував його в повсякденному житті, щоб побачити реальні кейси, і виправляти їх. А виправляти було що, багато чого показалось на практиці, про що я б не подумав заздалегідь. Я тестував і фіксив, тестував і фіксив.

Далі наступив момент, щоб випустити його в світ, і розповісти про нього людям, дізнатись чи взагалі цікаво це комусь, чи це просто ще один мій успішний професійний щабель розвитку.

Я вирішив створити канал на ютубі, і там розповідати про чат, показувати як він працює і чому він безпечний. Та будувати довіру користувачів, будучи відкритим і показуючи все як є, оскільки в таких продуктах, користувач має довіряти тому, чим користується.

І яке ж було моє здивування, коли після публікацій він знайшов свого користувача. Люди почали користуватись і цікавитись чатом, писали про нього, запитували, ділились враженями, та пропозиціями. Я побачив, що те що починалось як цікавість і захоплення, переросло в продукт, має свого користувача, і тепер воно зацікавлює людей, і це була найбільша нагорода за мою роботу.

Роздуми та плани

Ось тому я тут, я хочу поділитись своєю історією та продуктом з вами. Можливо комусь відгукнеться мій шлях, чи продукт що я створив. Думаю багато хто переживав схоже, коли тебе переповнюють емоції від ідеї, а ще більше потім від реалізації.

Хочу закликати вас спробувати чат, і залишити свої думки та відгуки тут, або на сторінці чату в розділі відгуків. Безмежно буду вдячний за поради і зауваження, я лише на початку свого професійного шляху, і буду вдячний за думки більш досвідчених колег.

Посилання на проєкт: EchoChat

Також запрошую переглянути мій канал, там вже вийшло декілька відео, де я в простій і ненав’язливій формі влогу показую принцип роботи коду чату, та процес його розвитку та виправлення.

Мій канал: YouTube канал

Ще раз дякую. І всім гарного дня.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному4
LinkedIn
Ctrl + Enter
Ctrl + Enter

у меня такое в рамках лабораторной работы на 2-3 курсе было, только на с++ и без готовых классов, в которых шифрование уже реализовано...

і там розповідати про чат, показувати як він працює і чому він безпечний.

Подосліджувати і зробити якесь шифрування на прикладі меседжера чисто для себе — окей. Але важко це позиціонувати як щось безпечне. Невеличка порада, відкрити ось цю доку signal.org/docs і почитати про підходи, які там описані.

По перше, там класні статті, інженерам буде цікаво їх почитати, і можна багато нового для себе відкрити в частині криптопротоколів.
По друге, буде краще зрозуміло, як зробити дійсно безпечним меседжер, куди розвивати далі. Тому що ECDH (навіть не ECDHE) з AES-GCM вже ні в кого не асоціюється з безпекою і чимось прийнятним в меседжерах. Це вже змігрувало в лігу лабораторних робот та стартових уроків по криптографії, перед тим як переходити до більш складного на базі цього.

Дякую за рекомендації, я обов’язково перегляну запропоновані ресурси.
Це поки по факту перша версія чату, і я планую покращувати і вдосконалювати його, шукати кращі шляхи реалізації криптування і загальної безпеки.

Я трохи детальніше дослідив це питання і хотів би уточнити свій підхід.

Щодо ECDHE та ефемерності ключів у моєму випадку ключі фактично є ephemeral. Пара ключів генерується локально при створенні сесії й існує лише в межах активного чату. Після завершення сесії (закриття вкладки, розриву з’єднання або timeout) всі ключі та повідомлення знищуються, а наступна сесія створює вже нову пару ключів і новий shared secret.

Тобто forward secrecy в межах окремих сесій фактично зберігається, оскільки ключовий матеріал не перевикористовується між чатами та не зберігається сервером.

Також я свідомо обрав модель ephemeral-session чату без persistence. Середня тривалість сесії кілька хвилин(за відгуками користувачів), тому ротація ключів всередині однієї короткоживучої сесії наразі не виглядає для мене критично необхідною.

Але загалом згоден, що з точки зору більш mature messenger architecture можна рухатись далі в сторону HKDF, key ratcheting та більш складних протоколів, але не впевнений в реальні користі їх враховуючи особливість мого чату.

Підписатись на коментарі