Как стать автором
Обновить

Псалом параноика: мессенджер с максимальной безопасностью

Время на прочтение9 мин
Количество просмотров5K
Приветствую.

На фоне недавних новостей о многочисленных сливах данных из мессенджеров посетила мысль разработать концепт мессенджера, максимально устойчивого к взлому — пусть даже и ценой удобства. Подробности реализации, интересные проблемы и полные исходники — под катом.


Предпосылки


There’s a man by the door
in a raincoat
smoking a cigarette

Как уже упоминалось выше, на сегодняшний день большинство популярных мессенджеров так или иначе были уличены в проблемах с безопасностью. Была ли тому причиной техническая проблема, или же об этом вежливо попросили — история умалчивает, но на самом деле это и не важно. Факт в том, что данные были скомпроментированы.

Казалось бы, решением проблемы с технической стороны могут послужить self-hosted решения с открытым кодом, типа RocketChat или Wire. И в некотором смысле это действительно так, однако, как говорится — есть нюанс.

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

Во вторых, пользуясь подобным решением приходится доверять хостеру — и если сервер будет скомпроментирован, данные попадут в чужие руки. Именно эту проблему и было интересно решить.

Защита от скомпроментированного хостера


My brother’s with them, did I tell you?
His wife is Russian and he
keeps asking me to fill out forms.

Единственное решение данной проблемы — избегать хранения чувствительной информации на сервере. То есть нужно исходить из того, что любая информация, которая попадает на сервер, может оказаться в публичном доступе — и при этом она не должна быть раскрыта. Простой способ реализации этого подхода — если получатель(Алиса) и отправитель(Боб) заранее обмениваются публичными ключами друг друга, используя локальный компьютер или безусловно защищенный канал (например, конверт с шифрованной флешкой). Однако в таком случае — изчезает основная проблема шифра Вернама, который является более стойким чем классический RSA. Кроме того, он сильно проще в реализации и менее требователен к ресурсам, в связи с чем было принято решение использовать его.

В таком случае, флоу установки защищенного канала сообщений между сообщений будет выглядеть таким образом:
  • Алиса генерирует блокнот с множеством случайных страниц и передает его Бобу.
  • Боб получает файл с шифрами, сохраняет его к себе на устройство и уничтожает носитель.
  • Боб сообщает Алисе о готовности принять сообщение.
  • Алиса пишет сообщение, шифрует его своей копией блокнота и сообщает по открытому каналу Бобу как скачать зашифрованное сообщение и номер страницы блокнота, после чего уничтожает ее.
  • Боб скачивает сообщение и расшифровывает своим блокнотом, а затем тоже удаляет использованную страницу.

Генерация блокнота


500 mailers bought from
500 drug counters each one different
and 500 notebooks
with 500 pages in everyone.

Было принято решение хранить данные в формате JSON. Длина 1 страницы блокнота фиксирована и составляет 768 символов (цифра выбрана не случайно, при таком размере ключа и размере сообщения 1 сообщение будет занимать ровно 1 килобайт).

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

Исходный код
const crypto = require("crypto");
const fs = require('fs');


const MAX_MESSAGE_LENGTH = 768;

const readline = require('readline').createInterface({
    input: process.stdin,
    output: process.stdout
});

readline.question('Enter sheets amount: ', amount => {
    generateKeys(amount)
    readline.close();
});

const generateKeys = (amount) => {
    const keys = []
    for (let i = 0; i < amount; i++) {
        keys.push({
            number: i,
            key: toBase64(generateRealRandomString())
        })
    }
    saveFile(JSON.stringify(keys))
}
const generateRealRandomString = () => {
    let string = ''
    for (let i = 0; i < MAX_MESSAGE_LENGTH; i++) {
        string += String.fromCharCode(crypto.randomInt(0, 65530))
    }
    return string
}
const toBase64 = (srcString) => {
    return new Buffer(srcString).toString('base64');
}
const generateRandomString = (lenght) => {
    const alphabet = "QWERTYUIOPLKJHGFDSAZXCVBNMqwertyuioplkjhgfdsazxcvbnm";
    const shuffled = alphabet.split('').sort(() => 0.5 - Math.random()).join('');
    return shuffled.slice(0, lenght)
}

const saveFile = (content) => {
    const fileName = generateRandomString(20)
    fs.writeFile(`keys/${fileName}.json`, content, err => {
        if (err) {
            console.error(err);
        }
        console.log("Keys were generated")
    });
}


Что тут любопытного — по умолчанию, для кодирования символов в JS используется UTF-16. Изначальной задумкой было использовать для ключа последовательность символов до 65535, однако при этом некоторые символы «бились». Связано это с тем, что юникод хранится в нескольких символах подряд (в случае с UFT-16 — от 1 до 2х). И не все символы после расположения друг за другом расшифровываются корректно. Так как сообщение в общем смысле произвольное, гарантировать что такое не случится нельзя. Кроме того, экранировать символы тоже нельзя, путь вероятность случайного совпадения не высока — она отлична от нуля. В связи с этим, было принято решение после генерации случайных символов по основанию 65530 (последние 5 символов не используются осознанно, ниже уточню почему) кодировать их в base64. Это решение не лишено проблем, которые решаются уже на уровне приложения, но — при этом и возможна удобная отладка, и блокнот открывается корректно на абсолютно любом устройстве.

Пример блокнота
Для наглядности, длина ключа сокращена до 20 символов
[{"number":0,"key":"476W6LC477+94peZ47KB67OI7I6R5K6i6run65O866Wt6JaE4pWd6I606qia55Gs45S06ISZ45al5La2"},{"number":1,"key":"5rig7I6M74Cp5beT6KW85KyF76WU6a+A7Yqe47C06IG05r2c5KqN5aSW6a2S6Z6Mx7Lihbnuj4DsvLs="},{"number":2,"key":"57u57IGJ6aO47o2B65Sp57Sr57Wd6Iq06r247JK/55mC666/6Km56IOG4bCA74im5J+m5Imj74694b6w"}]


Сервер


I’ve put him in my diary
and the mailers are all lined up
on the bed, bloody in the glow
of the bar sign next door

Сервер — максимально простая часть приложения. Он принимает POST запрос от Алисы, записывает его в базу, а взамен отдает ей хэш. После того, как Алиса получила хэш — она передает его Бобу, который в свою очередь передает его как параметр GET запроса — и получает зашифрованное сообщение. После получения данных Бобом, сообщение удаляется. В случае любого нештатного поведения — выбрасывается внутренняя ошибка.

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

Еще из примененных концепций — искусственное ограничение числа запросов в секунду. Служит защитой от брутфорса и в качестве снижения нагрузки на систему.

Полностью приводить код бекенда тут не вижу смысла, полные исходники доступны на гитхабе, по ссылке внизу

Приложение


In the rain, at the bus stop.
black crows with black umbrellas
pretend to look at their watches, but
it’s not raining.

Приложение — как мне кажется, наиболее интересная часть проекта. Именно тут происходит шифрование и дешифрование сообщений, проверка хэшей и — сокрытие функционала. Но обо всем по порядку.

Подгрузка ключа


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

В связи с этим, ключ выбирается с помощью пикера документов, открываемого по кнопке, десериализуется и записывается в состояние компонента — чтения или записи. Как только компонент будет размонтирован — данные о ключе будут потеряны. Кроме всего прочего, такой подход позволяет использовать множество разных блокнотов, например — для разных собеседников.

Передача данных в на сервер


Сначала в паре слов опишу графический интерфейс — в иллюстративных целях.
Для отправки сообщения производятся следующие действия:
  • Выбирается блокнот
  • Вводится номер используемой страницы блокнота
  • Вводится текст,. подлежащий шифрованию
  • Нажимется кнопка «Отправить»

Если все идет хорошо, то после этого кнопка шифрования заменится на кнопку копирования, а текст сообщения заменится на JSON строку с данными о хэше и номере страницы блокнота.

В картинках
До шифрования:

После шифрования (обрезано из соображений компактности):


При нажатии на кнопку «Зашифровать» происходят следующие дествия:
  • В объекте с ключами находится ключ с выбранным номером. При его отсутсвии кидается ошибка
  • Исходное сообщение кодируется в base64.
  • Происходит шифрование выбранным ключом.
  • Сообщение отправляется на сервер, в ответ приходит хэш.
  • Из хэша и номер страницы формируется JSON который подставляется в текстовое поле.

Более подробно стоит остановиться на функции шифрования. Изначально было желание брать код символа i-ого символа ключа и сообщения, XOR`ить их и записывать в результирующее сообщение символ с получившимся кодом символа. Однако подход «в лоб» был неудачным — ввиду того, что нельзя гарантировать, что результат операции тоже будет входить в 64-х символьное подмножество.

В связи с этим, был предпринят следующий шаг — была сформирована строка из 65 символов, используемых в base64, и вместо кодов символов используется индекс в этой строке.

Полный код функции шифрования/дешифрования приведен под спойлером:

Функция шифрования/дешифрования
const charSet = "QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm0123456789+/=";
const getIndexByChar = (char) => {
    return charSet.indexOf(char)
}
const getCharByIndex = (index) => {
    return charSet[index]
}
export const encode = (text, key) => {
    let encodedString = ""
    for (let i = 0; i < text.length; i++) {
        if (text[i] === "=" || text[i] === undefined) break;

        const orderedTextCharCode = getIndexByChar(text[i]);
        const orderedKeyCharCode = getIndexByChar(key[i]);

        const orderedEncryptedChar = getCharByIndex(orderedKeyCharCode ^ orderedTextCharCode)
        encodedString = encodedString + orderedEncryptedChar
    }

    return encodedString
}


Знак равенства служит для выравнивания длины перекодированного сообщения по границе байтов, в данном случае им можно пренебречь.

Получение данных с сервера


При получении данных с сервера цепочка раскручивается в обратном порядке. Сначала Боб выбирает тот же самый блокнот, который использовала Алиса. Затем — вводит JSON, полученный от нее в поле ввода и нажимает «Расшифровать», после чего код заменяется на исходное сообщение.

Последовательность действий тут полностью инвертирована относительно шифрования — сначала приложение собирает требуемые данные, потом по хэшу скачивает сообщение, дешифрует его с помощью ключа и переводит в UTF-16.

Единственное интересное дополнение здесь — проверка корректности хэша. После получения сообщения, приложение хэширует его уже локально и проверяет, что получившийся хэш совпадает с введенным хэшем — в противном случае можно утверждать, что сервер был скомпроментирован и сообщение было подменено.

Экран-обманка


Подобное приложение в чистом виде — как минимум, провоцирует вопросы. Для того, чтобы обезопасить информацию о раскрытии еще и с этой стороны, было принято решение добавить ложный экран, который выполняет какой-либо не подозрительный функционал, но позволяет по совершению некоторого неочевидного действия перейти в режим получения/передачи защищенных сообщений.

В версии, представленной в репозитории, в качестве экрана обманки служит секундомер. Секундомер не красивый, не удобный, но формально — полностью рабочий и главное — не вызывающий подозрений.

Пример секундомера


Его назначение в другом. Если остановить таймер на 4х секундах, с погрешностью не более 0,5 секунды, то после долгого зажатия заголовка приложение будет перенаправлено на экран отправки сообщений. И тут может быть на самом деле любое приложение и любая логика, главное, чтобы она нормально воспроизводилась только тем, кто знает как это воспроизвести — например, змейка, в которой надо 4 раза подряд проиграть на 5 очках или калькулятор, в котором надо посчитать ln(-pi^e).

А режим навигации с помощью жестов позволяет вернуться на подобный экран буквально в 1 нажатие. Этот функционал даже был вынесен в отдельный хук.

Навигация в 1 касание
export const useGestureNavigation = (touchX, touchY, setTouchX, setTouchY, xDest)=>{

    const navigate = useNavigate();
    const gestureEnd = (e) => {
        console.log(e.nativeEvent)
        const xDiff = e.nativeEvent.locationX - touchX
        const yDiff = e.nativeEvent.locationY - touchY

        if (Math.abs(yDiff) > constants.Y_SENSITIVITY) {
            navigate(ROUTES.MOCK)
            return
        }

        if (Math.abs(xDiff) > constants.Y_SENSITIVITY) {
            navigate(xDest)
        }

    }
    const gestureStart = (e) => {
        setTouchX(e.nativeEvent.locationX);
        setTouchY(e.nativeEvent.locationY)
    }
    return [gestureStart, gestureEnd]
}


Данные хендлеры добавляются на View обертки для экранов чтения и записи, позволяют переключаться между ними в 1 свайп, а при вертикальном свайпе — возвращаться обратно на экран-заглушку.

Кроме того, если выставить в константах небольшую чувствительность по Y, а после расшифровки сообщения поставить на экран палец и немного его подвигать — сообщение, как и свя информация о нем, пропадет как только палец будет снят с экрана. После чего снова увидеть сообщение не получится — с сервера оно удалено, а состояния очищены. Конечно, кэши никто не отменял, но это едва ли можно считать реальным способом получения информации — нормально доступно оно только в режиме отладки.

Заключение


Did I tell you I can’t go out no more?
There’s a man by the door
in a raincoat.

Можно ли неправомерно получить доступ к информации, передаваемой подобным образом? Да, в теории можно. Для этого нужно иметь доступ к блокноту, иметь доступ к каналу связи между Алисой и Бобом, по которому они передают хэши (условно — почта или Телеграм), доступ к приложению, а также иметь доступ к серверу, чтобы восстановить сообщение после удаления — или отключить удаление сообщений, чтобы Боб ничего не заподозрил.

Но — в случае, если скомпроментировано 2 любох звена — канал связи останется надежным. Кроме того, если скомпроментированы 3 любых звена — сообщение может быть прочитано и не доставлено, но не может быть искажено.

Анализировал варианты, чем текущий подход может быть уязвим — не удалость что-либо найти. Кроме того, не удалость найти и аналогов с сопоставимой степенью защиты.

На текущий момент приложение скорее представляет из себя концепцию, нежели готовый продукт — но оно полностью работоспособно, и я не возражаю против его использования во имя Добра, но не во имя Зла.

Исходники
Теги:
Хабы:
Всего голосов 6: ↑4 и ↓2+2
Комментарии19

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань