Olá, Habr! Meu nome é Artur Valiev, e sou o desenvolvedor de um pequeno aplicativo móvel chamado "Echo Layer". Por muito tempo, uma ideia me perseguiu: quase todas as discussões sobre comunicação privada, em algum momento, não se concentram em criptografia, arquitetura ou outros aspectos técnicos, mas sim na inércia humana. Podemos construir um sistema bonito, torná-lo open source, implementar end-to-end encryption, falar sobre zero knowledge e ausência de logs no servidor, mas, no final das contas, as pessoas tendem a permanecer onde se sentem confortáveis. Seja no Telegram, WhatsApp, VK, MAX, chats corporativos, e-mail ou comentários, a realidade é que, se a privacidade exige que todos migrem para um novo aplicativo, a tarefa está quase fadada ao fracasso.
Foi nesse momento que meu foco mudou. Parei de pensar em um aplicativo de mensagens como o produto final. Tornei-me mais interessado em outra questão: seria possível não construir um novo canal de comunicação, mas sim integrar uma camada de privacidade sobre os canais já existentes? Em vez de pedir aos usuários para mudarem seus hábitos, quebrarem seus fluxos de comunicação ou convencerem seus contatos a "migrarem para cá", a ideia era posicionar-se discretamente entre o usuário e o texto. Afinal, na maioria dos casos, um aplicativo de mensagens é apenas uma interface. Internamente, ainda há o texto que o usuário digitou e o texto que o aplicativo enviará. Portanto, se pudermos controlar o texto antes do envio, podemos controlar uma parte significativa da comunicação.
Assim nasceu o Echo Layer. Não como um "novo chat seguro", mas como uma tentativa de transformar a própria entrada de texto em um local onde a privacidade possa residir. Isso me levou a um ponto peculiar do ecossistema Android: o InputMethodService. Para um usuário comum, um teclado parece apenas um conjunto de botões. No entanto, para um desenvolvedor, é uma camada de sistema bastante poderosa. O teclado ocupa uma posição única: ele não possui o aplicativo em que o usuário está digitando, mas está conectado ao campo de texto. Ele pode inserir texto, ler parte do contexto, substituir fragmentos, reagir à seleção, interceptar o envio e funcionar em qualquer chat ou aplicativo que permita entrada de texto. Essa propriedade me cativou: o teclado é uma interface universal que já está no ponto certo do sistema. Ele está literalmente entre a pessoa e sua mensagem.
A partir daí, o projeto começou a tomar forma em minha mente não como um aplicativo de mensagens, mas como um teclado com uma camada adicional de lógica. Externamente, o usuário simplesmente digita. Internamente, o teclado pode fazer algo mais com a mensagem: criptografá-la, empacotá-la, escondê-la em um carrier comum e, se necessário, descriptografá-la posteriormente. A tarefa era, portanto, não inventar um novo UX de comunicação, mas garantir que o UX familiar permanecesse quase inalterado, enquanto o texto ganhasse uma segunda camada de significado oculta.
No papel, tudo isso soava bem. No código, descobri rapidamente que o ambiente de InputMethodService (IME) do Android é muito caprichoso, e a maioria das ideias "elegantes" se chocam com a realidade da integração. O problema mais desagradável e fundamental é que o teclado não possui o texto de terceiros. Este é, provavelmente, o fato mais importante a entender sobre qualquer desenvolvimento baseado em InputMethodService. O teclado opera através de um InputConnection, o que significa que todo o acesso ao texto ocorre apenas através da interface que o aplicativo específico decide fornecer. Em um campo, você pode obter quase todo o rascunho; em outro, apenas um pedaço ao redor do cursor; em um terceiro, o aplicativo fornece o mínimo de dados, forçando você a adivinhar o que está acontecendo. A expectativa comum é: "Bem, o teclado vê o que estou digitando, então ele sabe todo o texto". Na prática, isso está longe de ser verdade. Às vezes, ele vê a seleção, às vezes o texto circundante, às vezes pode solicitar ExtractedText, e outras vezes tudo isso funciona de forma parcial e instável.
É por isso que uma parte bastante importante do projeto não se tornou o próprio cifrador, mas a lógica para determinar o texto-alvo. Antes de codificar ou reescrever algo com IA, era preciso entender: o que exatamente é a mensagem neste momento? O fragmento selecionado? Todo o rascunho? O texto antes do cursor? O texto depois dele? É seguro substituí-lo de volta? Se houver um erro nesta etapa, todo o resto se torna irrelevante, pois ou o pedaço errado será criptografado, ou o rascunho será corrompido, ou o usuário verá um comportamento estranho e deixará de confiar no teclado. Portanto, uma camada separada de resolução de alvo foi introduzida no projeto. Ela tenta agir da forma mais pragmática possível. Se houver uma seleção, ótimo, podemos trabalhar com ela. Se não houver seleção, há uma chance de extrair o texto circundante. Se o aplicativo permitir, é melhor solicitar ExtractedText e tentar trabalhar com um rascunho mais completo. Isso não é uma questão de beleza arquitetônica, mas sim de sobrevivência em dispositivos reais. Percebi rapidamente que, em tal sistema, não se pode confiar em "acesso perfeito ao campo", pois ele quase nunca existe.
Quando a determinação do texto se tornou mais ou menos clara, começou a próxima parte – a própria criptografia. Aqui, conscientemente, não tentei inventar minha própria criptografia. Esse é um caminho muito ruim. Eu queria usar um pipeline claro, testado e prático que pudesse ser explicado adequadamente. Portanto, o esquema resultante foi o seguinte: existe o plaintext original, ou seja, o texto normal da mensagem. Primeiro, ele é convertido em bytes UTF-8. Este é um passo banal, mas importante: não se criptografa uma "string como abstração", mas uma sequência específica de bytes. Em seguida, o sistema pode decidir se deve aplicar compressão. A compressão aqui não é por eficiência abstrata, mas por uma razão bastante prática: os carriers em chats são limitados, o payload oculto pode crescer rapidamente, e qualquer redução de volume realmente ajuda. No entanto, a compressão não é aplicada sempre, mas apenas quando faz sentido em termos de tamanho, pois não há sentido em complicar o envelope se o texto já for curto.
Após isso, começa a parte criptográfica. Um salt aleatório é gerado. Ele é necessário para que a mesma passphrase do usuário não se transforme na mesma chave de forma idêntica a cada vez. Simplificando, o salt é uma impureza aleatória que torna a derivação da chave única para cada mensagem. Mesmo que o usuário use a mesma senha para várias mensagens, a presença de um novo salt aleatório significa que a chave derivada final não se repetirá deterministicamente como seria sem o salt. Isso protege contra uma classe inteira de correspondências indesejadas e pré-cálculos.
Em seguida, a chave é derivada da passphrase e do salt através de PBKDF2WithHmacSHA256. Por que o PBKDF2 é necessário aqui, se poderíamos simplesmente pegar a senha e fazer seu hash? Porque a senha de um usuário é quase sempre o ponto fraco. As pessoas escolhem não chaves aleatórias de 256 bits, mas palavras, frases, combinações que podem ser adivinhadas. O PBKDF2, neste caso, serve como um mecanismo para "aumentar o custo" da adivinhação. Ele torna a derivação da chave deliberadamente mais lenta devido a um grande número de iterações. No Echo Layer, são usadas 120.000 iterações para isso. Este não é um número mágico nem uma proteção absoluta, mas é um custo de engenharia razoável que torna os ataques de força bruta mais caros do que o trabalho direto com uma senha bruta. Ou seja, a tarefa do PBKDF2 aqui não é "criptografar a senha", mas tornar a obtenção de uma chave de trabalho a partir de uma passphrase humana computacionalmente não trivial.
Após a derivação da chave, um nonce é gerado, também conhecido como vetor de inicialização para o modo GCM. Esta é outra quantidade aleatória, mas com um papel diferente. Se o salt é necessário na fase de derivação, o nonce é necessário para a criptografia direta. No AES/GCM/NoPadding, reutilizar a mesma chave com o mesmo nonce é uma ideia muito ruim. Portanto, o nonce deve ser único para cada operação de criptografia. No Echo Layer, ele tem 12 bytes de comprimento, o que se alinha bem com o uso prático do GCM.
A própria criptografia é realizada através de AES-256-GCM. Por que GCM especificamente? Porque aqui não é apenas a confidencialidade que importa, mas também a integridade. Quando criptografamos uma mensagem, não basta apenas ocultar o texto. Precisamos também entender que o payload não foi corrompido ou substituído. O GCM é um modo de criptografia autenticada, o que significa que ele fornece tanto criptografia quanto verificação de autenticidade através de uma tag. Se alguém tentar alterar o ciphertext ou danificá-lo durante a transmissão, a descriptografia simplesmente não deve retornar silenciosamente lixo. Ela deve terminar com um erro de verificação. Este é um ponto muito importante para qualquer UX relacionado a segredos: é melhor dizer honestamente "falha ao descriptografar" do que mostrar ao usuário algo incorreto como se fosse uma mensagem válida.
Após a criptografia, o próprio envelope é formado – o contêiner da carga útil. Essencialmente, é um empacotamento de todos os dados necessários para a operação inversa. Dentro dele, são armazenados a versão do formato, o sinalizador de uso de compressão, o salt, o nonce, o ciphertext e tudo o mais necessário para uma decodificação correta. Esta também é uma camada arquitetônica bastante importante. Se simplesmente "empilharmos bytes em sequência", obteremos uma construção frágil, difícil de expandir e manter. O envelope torna o formato explícito: podemos versioná-lo, manter a compatibilidade, analisar erros e não adivinhar como interpretar um payload específico.
E aqui, pode-se pensar que o mais difícil já passou. Mas, na prática, é neste ponto que os problemas reais começam, porque o payload criptografado ainda precisa ser embutido no texto de forma que ele sobreviva ao envio através de aplicativos comuns.
Inicialmente, a ideia parecia quase romântica: já que estamos criando "mensagens invisíveis", deveríamos simplesmente esconder os dados em caracteres Unicode invisíveis. Tecnicamente, isso funciona: existem caracteres de formato, vários mecanismos de largura zero e similares, podemos codificar bits em sequências desses caracteres e adicioná-los à mensagem de forma que ela permaneça visualmente normal. Em demonstrações, isso produz um bom efeito. Você vê a frase "Olá!", e uma camada adicional vive dentro dela. Mas então o mundo real começa. Um aplicativo de mensagens limpa alguns caracteres. Outro normaliza o Unicode. Um terceiro move ou reempacota a string. Um quarto pode inesperadamente dividir o rascunho. Em algumas firmwares, a mesma mensagem se comporta de forma diferente no campo de entrada e na forma já enviada.
Por causa disso, o projeto teve que abandonar rapidamente a ideia de um "único carrier correto". Em vez disso, surgiram vários modos. Existe um modo que usa caracteres de formato invisíveis. Há uma opção com substituições de homóglifos, onde os dados são embutidos através de glifos semelhantes. Existe um modo de espaço em branco. E há um token compacto visível como fallback. Este último surgiu por pura necessidade. Porque caudas ocultas longas em chats se mostraram perigosas: a mensagem poderia ficar muito grande, o carrier poderia fisicamente não caber, o texto era dividido em vários envios, caudas vazias apareciam repentinamente, e às vezes o aplicativo começava a pensar que a mensagem continha algo semelhante a um link e anexava uma prévia. Ou seja, a invisibilidade "mágica" demais no ambiente real começou a prejudicar a confiabilidade.
Foi então que surgiu uma ideia bastante pragmática: é melhor ser menos discreto às vezes, mas mais resiliente. Assim surgiu o fallback VISIBLE_TOKEN. Ele não tenta mais fingir que nada aconteceu. Ele simplesmente representa de forma compacta o envelope criptografado em um formato que sobrevive melhor aos cenários de chat. Para o usuário, isso pode ser menos impressionante, mas para o sistema, provou ser muito mais estável.
A propósito, foi dessa mesma experiência que surgiu o modo AUTO, que considero uma das partes práticas mais úteis do projeto. Ele não funciona com o princípio "sabemos de antemão a melhor maneira", mas com o princípio "tentaremos e nos verificaremos imediatamente". Ou seja, o sistema codifica o payload usando um método, depois tenta imediatamente executá-lo através de uma autocodificação e, se a viagem de ida e volta não for bem-sucedida, muda para o próximo carrier. Esta solução não surgiu de um amor pela redundância, mas da desconfiança no ambiente. Para ser honesto, no desenvolvimento Android para IME, essa é uma abordagem saudável em geral: confiar menos em expectativas abstratas, verificar mais o comportamento real.
Uma história separada é a decodificação. Aqui, percebi bem cedo que a descriptografia não deve quebrar o texto no próprio chat. Era muito tentador criar um comportamento "mágico": o usuário copia ou vê uma mensagem, o teclado a reconhece e substitui automaticamente o carrier oculto pelo plaintext aberto diretamente no campo. Mas, na prática, isso é muito agressivo. Primeiro, nem sempre é seguro mexer no conteúdo do campo de entrada. Segundo, o usuário pode simplesmente estar lendo o chat, sem querer substituir nada. Terceiro, há o risco de destruir o rascunho ou causar efeitos colaterais inesperados no aplicativo específico. Portanto, a decodificação acabou sendo transferida para a faixa de candidatos/T9 dentro do próprio teclado. Isso é menos impressionante, mas muito mais honesto em termos de UX: o teclado mostra que conseguiu extrair e descriptografar o payload, mas não interfere no chat sem uma ação explícita do usuário.
E aqui também surgiu uma dor de engenharia separada. Inicialmente, a decodificação deveria ser exibida através de um popup ou overlay, ou seja, como um elemento flutuante sobre a interface. Em alguns dispositivos, isso parecia bonito. E então começaram as clássicas "alegrias" do Android: ciclo de vida instável, comportamento estranho no MIUI, diálogos anexados que se fecham instantaneamente, problemas com temas, falhas na medição de fontes, comportamento incorreto com o botão Voltar, discrepâncias entre AOSP e firmwares reais. No final, foi necessário adotar uma solução menos "brilhante", mas mais resiliente – construir sua própria camada de apresentação de candidate-strip dentro do próprio teclado. Este é exatamente o caso em que parece de fora: "bem, apenas mostrei uma linha de dica". E por dentro, horas de luta com insets, ciclo de vida, temas, redesenho e um monte de problemas muito terrenos que geralmente não são visíveis em um diagrama arquitetônico.
Outra camada interessante do projeto é a decodificação da área de transferência. À primeira vista, você gostaria que o teclado pudesse sempre notar: o usuário copiou algo, e há uma mensagem criptografada lá, então ele pode descriptografá-la imediatamente. Mas aqui há uma limitação da própria classe IME: o teclado só vive enquanto está ativo. Se o InputMethodService já foi descarregado, não há ninguém para ouvir a área de transferência. Isso significa uma coisa muito desagradável, mas honesta: "decodificação de área de transferência sempre ativa" dentro de um teclado puro é impossível. Para um comportamento verdadeiramente "sempre ativo", é necessário um componente separado no nível do aplicativo, e isso é uma arquitetura completamente diferente e um equilíbrio de privacidade diferente. Portanto, dentro do Echo Layer, foi necessário construir cenários mais cautelosos: decodificação enquanto o teclado está ativo, decodificação pendente na próxima abertura e renúncia a promessas que o IME tecnicamente não pode cumprir.
Paralelamente ao modo invisível, a reescrita por IA também surgiu no projeto. Isso cresceu quase naturalmente. Se o teclado já consegue extrair o rascunho, significa que ele pode não apenas criptografá-lo, mas também reescrevê-lo. Por exemplo, tornar o texto mais educado, compactá-lo, reformulá-lo, dar-lhe um tom diferente. Mas aqui também, quase todas as ideias bonitas rapidamente colidem com a realidade do IME do Android. Para enviar texto para um LLM externo, primeiro é preciso entender qual texto considerar o rascunho atual. E já sabemos que o teclado nem sempre vê o campo completamente. Portanto, foi necessário construir um AiDraftResolver separado, que primeiro tenta obter ExtractedText e, em seguida, se isso não for possível, recorre a um fallback de surrounding seguro. Isso parece um detalhe, mas sem essa camada, a reescrita por IA se torna imprevisível: ela pode reescrever o pedaço errado, perder parte do texto ou funcionar apenas em campos "ideais".
A camada de rede de IA também teve que ser feita da forma mais prática possível. Em vez de se vincular a um único provedor, foram criados adaptadores para OpenAI, OpenRouter, Ollama e Yandex. E lá também há muitas soluções não acadêmicas: se o usuário digitou um URL sem esquema, adicione http://; se for Ollama, adicione automaticamente /api/chat; se for uma API compatível com OpenAI, adicione /chat/completions; para Yandex, monte o endpoint correto. Externamente, isso parece pequenos conveniências. Na prática, é mais um exemplo de como o projeto sai constantemente do nível de "arquitetura ideal" para o nível de "ajudar uma pessoa real a não tropeçar em uma barra extra".
Em geral, se tentarmos formular honestamente qual foi a principal dificuldade no desenvolvimento do Echo Layer, eu diria o seguinte: o problema não foi criptografar o texto. Com isso, tudo é relativamente claro. A principal dificuldade foi fazê-lo dentro de um ambiente muito limitado, instável e alheio, onde você não controla o campo de entrada, o aplicativo de chat, o firmware ou o fluxo do usuário até o fim. Em um aplicativo comum, você é o dono da interface e dos dados. Em um teclado, você está sempre "de visita". E você precisa se comportar com muito cuidado, porque qualquer movimento brusco pode facilmente quebrar a UX.
É por isso que muitas soluções no projeto podem parecer "truques" ou até mesmo hacks. Mas, na verdade, são camadas de engenharia de proteção. Concluir o estado de composição antes da substituição. Fazer uma autocontrole para o modo AUTO. Armazenar a decodificação pendente. Não exibir a descriptografia através de um overlay. Recusar a codificação parcial. Selecionar o modelo de cobertura não apenas pela estética, mas também pela capacidade do carrier. Separar rewrite only e rewrite before send. Tudo isso não são enfeites nem overengineering. É uma reação a problemas reais de tempo de execução que se manifestam apenas em dispositivos reais, em chats reais e em combinações muito inconvenientes de versões do Android e firmwares.
Como resultado, o Echo Layer se mostrou interessante para mim não apenas como uma ideia sobre privacidade, mas também como um experimento de engenharia bastante honesto. Comecei com a ideia "será que dá para tornar a privacidade quase invisível?" e cheguei a uma compreensão muito mais prática: a invisibilidade por si só não significa muito se a solução for instável. Às vezes, é melhor escolher um caminho menos impressionante que sobrevive ao mundo real. Às vezes, é melhor mostrar um token compacto do que tentar a todo custo manter a magia da cauda oculta. Às vezes, é melhor mover a decodificação para a faixa de candidatos do que quebrar o chat em nome do "efeito uau". Às vezes, é melhor admitir que o IME não é onipotente do que construir ilusões.
Provavelmente, esta é a filosofia do projeto à qual cheguei no final. Privacidade não é apenas sobre criptografia. É também sobre a forma como a tecnologia se integra ao cotidiano. Se uma solução exige que o usuário realize um ritual separado, um aplicativo separado, uma disciplina separada, ela já perde para o hábito. Mas se ela vive no momento da digitação, exatamente onde a pessoa já está escrevendo a mensagem, ela tem a chance de se tornar natural.
O Echo Layer não reivindica o papel de uma solução universal ou um "assassino de aplicativos de mensagens". É mais um manifesto honesto, por vezes cru, de criptoanarquia no código. É uma exploração de quão profundamente a privacidade pode ser incorporada a uma camada do sistema sem forçar o usuário a mudar seu estilo de vida habitual.
E, talvez, é por isso que este projeto ainda está vivo para mim. Não se trata de um esquema bonito no vácuo. Trata-se de uma tentativa muito teimosa de embutir privacidade onde ela normalmente não é esperada – diretamente no teclado.
Criptografia Link para o GitHub Telegram — Artur Valiev


