Привет Хабр! Меня зовут Артур Валиев, и я разработчик небольшого мобильного приложения с названием «Echo Layer».

Меня давно не отпускала одна мысль: почти все разговоры о приватной переписке в какой‑то момент упираются не в шифрование, не в криптографию, не в архитектуру, а в банальную человеческую инерцию. Можно сколько угодно строить красивую систему, можно делать open source, end‑to‑end encryption, писать про zero knowledge и отсутствие серверных логов, но потом ты возвращаешься в реальную жизнь и видишь, что люди всё равно сидят там, где им удобно. В Telegram, в WhatsApp, в VK, сейчас уже и MAX, в корпоративных чатах, в почте, в комментариях, где угодно. И если для приватности нужно сначала всех пересадить в новое приложение, то задача почти обречена.

Именно в этот момент у меня и сместился фокус. Я перестал думать о мессенджере как о конечном продукте. Мне стало интереснее другое: а можно ли не строить новый канал связи, а встроить приватный слой поверх уже существующих каналов? Не просить человека менять привычки, не ломать его сценарий общения, не заставлять его уговаривать собеседника «перейти вот сюда», а аккуратно встать между пользователем и текстом. Потому что, если честно, в большинстве случаев мессенджер — это просто оболочка. Внутри всё равно есть текст, который пользователь написал, и текст, который приложение потом отправило. Значит, если контролировать текст до отправки, можно контролировать довольно много.

Так Echo Layer и появился. Не как «новый безопасный чат», а как попытка превратить сам ввод текста в место, где может жить приватность.

Дальше всё привело меня в довольно странную точку Android‑экосистемы — InputMethodService. Если смотреть на клавиатуру глазами обычного пользователя, она выглядит просто как набор кнопок. Но если смотреть глазами разработчика, это довольно мощный системный слой. Клавиатура находится в особом положении: она не владеет приложением, в котором печатает пользователь, но при этом она подключена к текстовому полю. Она может вставлять текст, читать часть контекста, заменять фрагменты, реагировать на выделение, перехватывать отправку, работать в любых чатах и практически в любом приложении, где есть ввод. Меня очень зацепило это свойство: клавиатура — это универсальный интерфейс, который уже находится в нужной точке системы. Она в буквальном смысле стоит между человеком и его сообщением.

С этого момента проект стал складываться в голове не как мессенджер, а как клавиатура с дополнительным слоем логики. Снаружи человек просто печатает. Внутри клавиатура может сделать с сообщением кое‑что ещё: зашифровать его, упаковать, спрятать в обычный carrier и, если нужно, потом расшифровать. То есть задача выглядела так: не придумать новый UX общения, а сделать так, чтобы привычный UX почти не менялся, но при этом у текста появился второй, скрытый смысловой слой.

На бумаге всё это звучало красиво. В коде довольно быстро выяснилось, что Android IME — очень капризная среда, и большинство «изящных» идей разбиваются о реальную интеграцию.

Самая неприятная и фундаментальная проблема в том, что клавиатура не владеет чужим текстом. Это, наверное, главный факт, который нужно понять про любую разработку на базе InputMethodService. Клавиатура работает через InputConnection, а это значит, что весь доступ к тексту идёт только через тот интерфейс, который конкретное приложение решило предоставить. В одном поле ты можешь получить почти весь черновик, в другом — только кусок вокруг курсора, в третьем приложение вообще отдаёт минимум данных, и ты вынужден угадывать, что там происходит. Обычное ожидание звучит так: «ну клавиатура же видит, что я печатаю, значит она знает весь текст». На практике это совсем не так. Иногда она видит выделение, иногда surrounding text, иногда может запросить ExtractedText, а иногда всё это работает частично и нестабильно.

Именно поэтому довольно важным куском стал не сам шифратор, а логика определения текста‑цели. Прежде чем что‑то кодировать или переписывать через AI, нужно было понять: а что именно сейчас является сообщением? Выделенный фрагмент? Весь draft? Текст перед курсором? Текст после него? Можно ли безопасно заменить его обратно? Если ошибиться на этом этапе, всё остальное уже не важно, потому что либо зашифруется не тот кусок, либо будет повреждён черновик, либо пользователь увидит странное поведение и перестанет доверять клавиатуре.

Поэтому в проекте появился отдельный слой разрешения цели. Он пытается действовать максимально прагматично. Если есть выделение — хорошо, можно работать с ним. Если выделения нет, есть шанс вытащить surrounding text. Если приложение позволяет — лучше запросить ExtractedText и попробовать работать с более полным черновиком. Это всё не вопрос красоты архитектуры, а вопрос выживания на реальных устройствах. Я довольно быстро понял, что в такой системе нельзя надеяться на «идеальный доступ к полю», потому что его почти никогда нет.

Когда с определением текста стало более‑менее ясно, началась следующая часть — собственно шифрование. Здесь я сознательно не пытался изобрести свою криптографию. Это очень плохая дорожка. Мне хотелось использовать понятный, проверенный, практичный конвейер, который можно нормально объяснить. Поэтому схема получилась такой: есть исходный plaintext, то есть обычный текст сообщения. Сначала он переводится в байты UTF-8. Это банальный, но важный шаг: шифруется не «строка как абстракция», а конкретная последовательность байтов. Дальше система может решить, стоит ли применять сжатие. Сжатие здесь не ради абстрактной эффективности, а ради вполне практической причины: carriers в чатах ограничены, hidden payload быстро раздувается, а любое уменьшение объёма реально помогает. Но сжатие применяется не всегда, а только когда это даёт смысл по размеру, потому что нет смысла усложнять конверт, если текст и так короткий.

После этого начинается криптографическая часть. Генерируется случайный salt. Он нужен для того, чтобы один и тот же passphrase пользователя не превращался каждый раз в один и тот же ключ одинаковым образом. Если говорить проще, salt — это случайная примесь, которая делает вывод ключа уникальным для каждого сообщения. Даже если пользователь использует один и тот же пароль для нескольких сообщений, наличие нового случайного salt означает, что итоговый derived key уже не будет детерминированно повторяться так, как без salt. Это защищает от целого класса нежелательных совпадений и предвычислений.

Дальше из passphrase и salt через PBKDF2WithHmacSHA256 выводится ключ. Зачем здесь вообще нужен PBKDF2, если можно было бы просто взять пароль и захешировать его? Потому что пароль человека — почти всегда слабое место. Люди выбирают не случайные 256-битные ключи, а слова, фразы, комбинации, которые можно подбирать. PBKDF2 в данном случае нужен как механизм «удорожания» подбора. Он делает вывод ключа заведомо более медленным за счёт большого числа итераций. В Echo Layer для этого используется 120 000 итераций. Это не магическое число и не абсолютная защита, но это разумная инженерная цена, которая делает атаки на перебор дороже, чем прямую работу с голым паролем. То есть задача PBKDF2 здесь не «зашифровать пароль», а сделать получение рабочего ключа из человеческой passphrase вычислительно нетривиальным.

После вывода ключа генерируется nonce, он же initialization vector для режима GCM. Это ещё одна случайная величина, но уже с другой ролью. Если salt нужен на этапе derivation, то nonce нужен непосредственно шифрованию. В AES/GCM/NoPadding повторное использование одного и того же ключа с одним и тем же nonce — это очень плохая идея. Поэтому nonce должен быть уникальным для каждой операции шифрования. В Echo Layer он имеет длину 12 байт, что хорошо соответствует практическому использованию GCM.

Само шифрование выполняется через AES-256-GCM. Почему именно GCM? Потому что тут важна не только конфиденциальность, но и целостность. Когда мы шифруем сообщение, нам мало просто скрыть текст. Нам нужно ещё понимать, что payload не был испорчен или подменён. GCM — это режим authenticated encryption, то есть он даёт и шифрование, и проверку аутентичности через tag. Если кто‑то попытается изменить ciphertext или повредить его при передаче, дешифрование просто не должно тихо вернуть мусор. Оно должно завершиться ошибкой проверки. Это очень важный момент для любого UX, связанного с секретами: лучше честно сказать «не удалось расшифровать», чем показать пользователю что‑то некорректное как будто это валидное сообщение.

После шифрования формируется тот самый envelope — контейнер полезной нагрузки. По сути это упаковка всех данных, необходимых для обратной операции. Внутри хранится версия формата, флаг использования сжатия, salt, nonce, ciphertext и всё, что нужно для корректного decode. Это тоже довольно важный архитектурный слой. Если просто «накидать байты подряд», получится хрупкая конструкция, которую трудно расширять и поддерживать. Envelope делает формат явным: мы можем версионировать его, поддерживать совместимость, разбирать ошибки и не гадать, как интерпретировать конкретный payload.

И вот здесь, казалось бы, самое сложное уже позади. Но на практике именно на этом месте начинаются реальные проблемы, потому что зашифрованный payload ещё нужно встроить в текст так, чтобы он вообще пережил отправку через обычные приложения.

Сначала идея выглядела почти романтично: раз мы делаем «невидимые сообщения», значит надо просто прятать данные в invisible Unicode‑символы. Технически это работает: есть форматные символы, есть разные zero‑width и похожие механизмы, можно кодировать биты в последовательности таких символов и добавлять их в сообщение так, чтобы визуально оно оставалось обычным. На демо это производит хороший эффект. Ты видишь фразу «Привет!», а внутри живёт ещё один слой. Но потом начинается реальный мир. Один мессенджер чистит часть символов. Другой нормализует Unicode. Третий переносит или перепаковывает строку. Четвёртый может вообще неожиданно разбить draft. На каких‑то прошивках одно и то же сообщение ведёт себя по‑разному в поле ввода и в уже отправленном виде.

Из‑за этого проекту пришлось довольно быстро отказаться от идеи «одного правильного carrier». Вместо этого появился набор режимов. Есть режим, который использует invisible format chars. Есть вариант с homoglyph‑заменами, когда данные встраиваются через похожие глифы. Есть whitespace‑режим. И есть видимый компактный токен как fallback. Последний вообще появился из чистой необходимости. Потому что длинные скрытые хвосты в чатах оказались опасны: сообщение могло стать слишком большим, carrier мог физически не влезть, текст дробился на несколько отправок, внезапно появлялись пустые хвосты, а иногда приложение начинало думать, что в сообщении есть что‑то похожее на ссылку, и подцепляло preview. То есть слишком «магическая» невидимость в реальной среде начала вредить надёжности.

Тогда и родилась довольно прагматичная мысль: лучше иногда быть менее незаметным, но более устойчивым. Так появился VISIBLE_TOKEN fallback. Он уже не пытается делать вид, что ничего не произошло. Он просто компактно представляет encrypted envelope в форме, которая лучше переживает чатовые сценарии. Для пользователя это может быть менее эффектно, но для системы это оказалось сильно стабильнее.

Кстати, именно из этого же опыта появился режим AUTO, который я считаю одной из самых полезных практических частей проекта. Он работает не по принципу «мы заранее знаем лучший способ», а по принципу «мы попробуем и сразу себя проверим». То есть система кодирует payload одним методом, потом пытается тут же прогнать self‑check через decode, и если roundtrip не проходит, переключается на следующий carrier. Это решение выросло не из любви к избыточности, а из недоверия к среде. Если честно, в Android‑разработке для IME это вообще здоровый подход: меньше верить абстрактным ожиданиям, больше проверять фактическое поведение.

Отдельная история — decode. Тут я довольно рано понял, что расшифровка не должна ломать текст в самом чате. Очень соблазнительно было сделать «магическое» поведение: пользователь копирует или видит сообщение, клавиатура его распознаёт и автоматически заменяет скрытый carrier на открытый plaintext прямо в поле. Но на практике это слишком агрессивно. Во‑первых, не всегда безопасно трогать содержимое input‑поля. Во‑вторых, пользователь может просто читать чат, не желая ничего заменять. В‑третьих, есть риск разрушить draft или вызвать неожиданные побочные эффекты в конкретном приложении. Поэтому decode в итоге был перенесён в candidate/T9 strip внутри самой клавиатуры. Это менее эффектно, но намного честнее с точки зрения UX: клавиатура показывает, что она смогла извлечь и расшифровать payload, но не вмешивается в чат без явного действия пользователя.

И здесь тоже вылезла отдельная инженерная боль. Изначально decode хотелось показывать через popup или overlay, то есть как всплывающий элемент поверх интерфейса. На некоторых устройствах это выглядело красиво. А потом начались классические Android‑радости: нестабильный lifecycle, странное поведение на MIUI, мгновенно закрывающиеся attached dialog, проблемы с темами, падения на font measurement, кривое поведение при Back, расхождения между AOSP и реальными прошивками. В итоге пришлось принять менее «глянцевое», но более живучее решение — строить собственный candidate‑strip presentation layer внутри самой клавиатуры. Это как раз тот случай, когда снаружи кажется: «ну просто показал строку подсказки». А внутри за этим стоят часы борьбы с insets, lifecycle, темами, перерисовкой и кучей очень земных проблем, которых в архитектурной диаграмме обычно не видно.

Ещё один интересный пласт проекта — clipboard decode. На первый взгляд хочется, чтобы клавиатура умела всегда замечать: пользователь что‑то скопировал, а там зашифрованное сообщение, значит можно сразу его расшифровать. Но тут есть ограничение самого класса IME: клавиатура живёт только пока она поднята. Если InputMethodService уже выгружен, слушать буфер обмена просто некому. Это означает очень неприятную, но честную вещь: «всегда активный clipboard decode» внутри чистой клавиатуры невозможен. Для truly always‑on поведения нужен уже отдельный компонент уровня приложения, а это совсем другая архитектура и другой баланс приватности. Поэтому внутри Echo Layer пришлось строить более осторожные сценарии: decode при живой клавиатуре, pending decode при следующем открытии и отказ от обещаний, которые IME технически не может выполнить.

Параллельно с invisible mode в проекте появился и AI rewrite. Это уже выросло почти естественно. Если клавиатура и так умеет достать draft, значит она может не только зашифровать его, но и переписать. Например, сделать текст более вежливым, сжать, переформулировать, привести к другому тону. Но и здесь почти все красивые идеи быстро сталкиваются с реальностью Android IME. Чтобы отправить текст во внешний LLM, сначала нужно вообще понять, какой текст считать текущим draft. А мы уже знаем, что клавиатура не всегда видит поле полностью. Поэтому пришлось строить отдельный AiDraftResolver, который сначала пытается получить ExtractedText, а потом, если это невозможно, переходит к безопасному surrounding fallback. Это вроде бы мелочь, но без такого слоя AI rewrite становится непредсказуемым: он может переписать не тот кусок, потерять часть текста или работать только в «идеальных» полях.

Сетевой слой AI тоже пришлось делать максимально приземлённым. Вместо привязки к одному провайдеру появились адаптеры под OpenAI, OpenRouter, Ollama и Yandex. И там тоже полно неакадемических решений: если пользователь ввёл URL без схемы, подставить http://; если это Ollama, автоматически дописать /api/chat; если OpenAI‑совместимый API — дописать /chat/completions; для Yandex собрать правильный endpoint. Снаружи это выглядит как мелкие удобства. На практике это ещё один пример того, как проект постоянно съезжает с уровня «идеальной архитектуры» на уровень «помочь реальному человеку не споткнуться о лишний slash».

Вообще, если попытаться честно сформулировать, в чём состояла главная сложность разработки Echo Layer, я бы сказал так: проблема была не в том, чтобы зашифровать текст. С этим всё относительно понятно. Главная сложность была в том, чтобы сделать это внутри очень ограниченной, нестабильной и чужой среды, где ты не контролируешь ни поле ввода, ни чат‑приложение, ни прошивку, ни пользовательский сценарий до конца. В обычном приложении ты сам хозяин интерфейса и данных. В клавиатуре ты всё время в гостях. И тебе нужно вести себя очень осторожно, потому что любое резкое движение легко ломает UX.

Именно из‑за этого многие решения в проекте могут показаться «хитростями» или даже хаками. Но на самом деле это защитные инженерные слои. Завершить composing‑state перед replace. Сделать self‑check для AUTO режима. Хранить pending decode. Не показывать расшифровку через overlay. Отказаться от partial encode. Подбирать cover template не только по эстетике, но и по вместимости carrier. Развести rewrite only и rewrite before send. Всё это не украшения и не overengineering. Это реакция на реальные runtime‑проблемы, которые проявляются только на живых устройствах, в живых чатах и в очень неудобных сочетаниях Android‑версий и прошивок.

В результате Echo Layer для меня оказался интересен не только как идея про приватность, но и как довольно честный инженерный эксперимент. Я начал с мысли «а можно ли сделать приватность почти невидимой», а пришёл к гораздо более приземлённому пониманию: невидимость сама по себе мало что значит, если решение нестабильно. Иногда лучше выбрать менее эффектный путь, который переживает реальный мир. Иногда лучше показать компактный токен, чем пытаться любой ценой сохранить магию скрытого хвоста. Иногда лучше вынести decode в candidate strip, чем ломать чат ради «вау‑эффекта». Иногда лучше признать, что IME не всесильна, чем строить иллюзии.

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

Echo Layer не претендует на роль универсального решения или «убийцы мессенджеров». Это скорее честный, местами сырой манифест криптоанархии в коде. Это исследование того, как глубоко можно внедрить приватность в системный слой, не заставляя пользователя менять привычный образ жизни.

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

Шифрование
Шифрование

Ссылка на GitHub
Telegram

— Артур Валиев

Комментарии (1)


  1. remindscope
    20.04.2026 20:48

    Интересный материал, спасибо