O notebook que ia para a assistência
Um dia o meu notebook precisou ir para a assistência técnica, e eu caí na conta de uma coisa óbvia que nunca tinha me incomodado: o técnico precisaria fazer login para testar a máquina, e mesmo que não precisasse, bastava tirar o SSD, ligar como disco secundário em outro computador e ler tudo. O disco não tinha criptografia. E "tudo", no caso, eram os arquivos .env de uma dúzia de projetos, chaves privadas de servidores, tokens de API, senhas de banco, credenciais de clientes. Nada disso estava escondido; estava em texto claro, em pastas com nomes óbvios, esperando.
A solução de curto prazo foi cifrar tudo que era sensível antes de entregar a máquina, e decifrar quando ela voltasse. A solução de longo prazo foi ligar a criptografia de disco inteiro, que hoje eu tenho e recomendo como base para qualquer pessoa. Mas ficou uma necessidade no meio: mesmo com o disco cifrado, há segredos que eu quero guardar num formato que sobreviva a um backup na nuvem, a um pendrive emprestado, a uma máquina alheia, e que seja aberto só quando eu precisar e só pelo tempo que eu precisar. Foi daí que saiu o SNS Security, e esse texto é sobre as decisões que ele carrega, porque em ferramenta de segurança as decisões importam mais do que as funcionalidades.
O que ele faz, em dez comandos
É um único script Python, sem servidor, sem nuvem, sem conta em lugar nenhum e sem nada instalado do PyPI. Ele cifra arquivos e pastas com uma frase-senha, abre o conteúdo numa sessão temporária que se destrói ao sair, e nunca modifica o arquivo cifrado.
./sns.py instalar-age # traz o núcleo criptográfico, verificado, só na primeira vez
./sns.py doutor # o que está pronto nesta máquina
./sns.py frase # gera 8 palavras; anote no papel
./sns.py cifrar .env # -> .env.sns
./sns.py cifrar ~/chaves/ -o k.sns
./sns.py abrir .env.sns # mostra no pager, nada toca o disco
./sns.py abrir .env.sns --clip # vai para a área de transferência e é limpo depois
./sns.py abrir k.sns # abre a pasta numa sessão em RAM, num subshell
./sns.py verificar k.sns # confere integridade sem extrair
./sns.py limpar # remove sessões órfãs
Roda em Linux, macOS e Windows, e o único pré-requisito é o Python. O resto do texto é sobre o que está por trás de cada uma dessas linhas.
A decisão mais importante: não implementar cifra nenhuma
A primeira versão tinha formato próprio. Cabeçalho de 40 bytes, derivação de chave por Argon2id ou scrypt com um gigabyte de memória, ChaCha20-Poly1305 em blocos autenticados, tudo escrito por mim em Python com a biblioteca cryptography. Funcionava, tinha testes, e eu tinha orgulho. E tinha dois problemas que não se resolvem em Python, por mais cuidado que se tenha.
O primeiro: a frase-senha existia dentro do processo. Em Python, del não zera memória, strings e bytes são imutáveis, e o interpretador faz cópias fora do seu alcance, no getpass, no .encode(), numa realocação qualquer. Não há como garantir que a frase sumiu da RAM depois de usada. O segundo: o parse de um arquivo hostil morava em código meu, que ninguém auditou. Os parâmetros de derivação de chave vinham do cabeçalho, antes de qualquer autenticação, então um arquivo forjado declarando memória absurda derrubava a máquina de quem o abrisse. Esse é o tipo de bug que um formato próprio sempre tem e que só aparece quando alguém procura.
A versão 2 trocou o núcleo pelo age, uma ferramenta de cifra de arquivos com especificação pública, ChaCha20-Poly1305 em construção STREAM, chave derivada por scrypt, e auditoria pública da Trail of Bits em 2024. O meu script não cifra mais nada. Ele orquestra o age: gera a frase, empacota o conteúdo, decide onde o texto claro vive, destrói a sessão no fim, e não mente sobre nada disso. Os dois problemas sumiram por construção, não por correção: quem lê a frase é o age, do terminal de controle, no processo dele, e quem faz o parse do arquivo é o age também. Não existe mais código meu em que a frase entre, e portanto não existe código meu ali para dar errado.
Ganha
- A frase nunca entra no processo Python
- Parse de arquivo hostil em código auditado, não no meu
- Um
.snsé um arquivo age comum:age --decryptabre sem o script existir - Zero dependências do PyPI, em qualquer uso
Paga
- Depende de um binário externo, que precisa estar presente
- O KDF ficou mais barato: scrypt de ~256 MiB em vez de 1 GiB
echo frase | ./sns.py cifrar xnão funciona, de propósito- O acervo do formato antigo precisa ser recifrado
Sobre o KDF mais barato: a 88 bits de entropia na frase, a diferença entre 256 MiB e 1 GiB por tentativa não muda nada para força bruta. Quem carrega a segurança é o gerador de frase, e ele não mudou. E sobre o formato antigo: a versão 3 parou de lê-lo e removeu o comando de migração, porque era o único comando em que a frase ainda passava pela memória do Python. Um comando deletado é a única mitigação que não regride. Quem tem acervo antigo roda a versão 2 numa cópia separada para recifrar, e o script atual reconhece o cabeçalho velho e explica exatamente isso, em vez de dizer "arquivo corrompido" para um arquivo que está intacto, só velho.
Oito palavras no papel
Frase que humano inventa é ruim de duas formas ao mesmo tempo: difícil de lembrar e fácil de adivinhar, porque segue padrão. O comando frase sorteia oito palavras da lista BIP-39, a mesma lista de 2.048 palavras que as carteiras de Bitcoin usam, com o gerador de números aleatórios do sistema operacional. A lista foi escolhida por três propriedades práticas para quem vai escrever à mão: 2.048 palavras são exatamente 11 bits cada, então oito palavras são 88 bits de entropia, uma conta e não uma estimativa; cada palavra é única nos quatro primeiros caracteres, então uma letra borrada no papel ainda se reconstrói; e são curtas, em inglês, sem acento e sem caractere especial, então não há dúvida entre "ç" e "c" nem teclado em outro layout atrapalhando. O script confere o hash da lista a cada execução, porque uma lista adulterada para menos palavras geraria frases mais fracas sem ninguém notar.
88 bits é suficiente? Cada tentativa de adivinhação custa uma derivação scrypt completa com cerca de 256 MiB de RAM, e é essa memória que quebra o ataque: força bruta industrial acontece em GPU e ASIC, onde o gargalo é memória por núcleo. 2⁸⁸ tentativas a esse custo não fecham em orçamento nenhum. Quem quer margem que não dependa do KDF pede doze palavras e tem 132 bits.
Perder a frase é perder os dados. Não existe recuperação, não existe "esqueci minha senha", e é assim de propósito: não existe porta dos fundos para você e, por consequência, não existe para mais ninguém. A frase vai para o papel, antes de cifrar qualquer coisa, e nunca para um arquivo desta máquina.
Abrir sem gravar: onde o texto claro vive
Cifrar é a parte fácil. A parte que define a ferramenta é o que acontece quando você precisa usar o segredo. A maioria das soluções decifra para um arquivo no disco e confia que você vai apagar depois, e apagar em SSD não garante nada: o nivelamento de desgaste e o journal do sistema de arquivos podem manter os blocos antigos por tempo indefinido. A proteção de verdade é nunca ter gravado o texto claro no disco.
| Situação | O que acontece |
|---|---|
| Arquivo único | Vai para o pager por pipe, sem tocar o disco. Ao sair, o conteúdo some com a tela alternativa do terminal |
Arquivo único, --clip | Vai para a área de transferência e é apagado dela depois de 25 segundos |
| Pasta | Extraída numa sessão temporária com acesso só do seu usuário, e abre um subshell já dentro dela. Você copia o que precisa e sai; nesse momento os arquivos são sobrescritos e removidos |
| Acima de 64 MiB | Abre sempre como pasta, em fluxo, sem carregar tudo na memória |
| Precisa em claro no disco | Só com --para CAMINHO, explícito, e o script avisa o que acabou de fazer |
Onde a sessão temporária mora é a maior diferença entre os três sistemas, e é por isso que o comando doutor existe: ele diz, na sua máquina, qual dos três casos é o seu. No Linux, a sessão vai para o /dev/shm, que é RAM. Não serve o /tmp, que na maioria das instalações é disco de verdade. No macOS não existe /dev/shm, e o /tmp é APFS, com Time Machine e snapshots locais que podem ter copiado antes de você apagar; então cada abertura cria um disco em RAM próprio, sem sudo, e o joga fora no fim. Se a criação falhar ou o tamanho passar de um quarto da memória física, o programa recusa em vez de cair para o disco caladamente. No Windows não existe tmpfs: a sessão de uma pasta vai para o disco, e o script avisa isso em voz alta, com o caminho exato, antes de pedir a frase. Quem quer fechar esse furo monta um disco em RAM e aponta a variável SNS_TEMP para ele. Arquivo único, que é o caso de senha e de chave, nunca abre sessão e não escreve em disco em sistema nenhum.
Dentro da sessão, nada do que você digita vira arquivo. O subshell roda com o histórico desligado, e o pager também, porque uma busca digitada dentro do less ficava gravada em ~/.lesshst e sobrevivia à sessão. Ao sair, o grupo de processos inteiro é morto, não só o shell, senão um editor ou um scp esquecido ficaria órfão segurando descritores dos arquivos. A destruição roda em finally, em atexit e em handlers de sinal, e há um --timeout para a janela que você esqueceu aberta. Se o processo morrer de um sinal que não dá para interceptar, a sessão fica órfã e a próxima execução a varre.
O arquivo como adversário
O adversário óbvio é quem tem o seu disco sem a sua frase. Há um menos óbvio, e dele saíram duas das fragilidades mais interessantes do projeto: o próprio arquivo. Alguém te manda um .sns junto com a frase, "o arquivo é esse, a senha é essa", e a partir daí o conteúdo que o programa interpreta foi escolhido por quem forjou o arquivo. A frase certa não faz o conteúdo ser honesto; ela só prova que o arquivo chegou como saiu.
O caso mais recente foi o tar esparso. O conteúdo vai sempre dentro de um tar, e o programa decide se abre em memória ou em fluxo pelo tamanho do arquivo cifrado, sob a premissa de que o age não comprime e o tar só adiciona bytes. A premissa é falsa para um membro esparso: o formato GNU declara um tamanho de N gigabytes gastando 512 bytes, porque buraco não ocupa espaço. Um arquivo de 1,7 KiB passava pelo limite, caía no caminho de memória, e a leitura materializava os N gigabytes na RAM de quem abria. A regra que ficou é que tudo que vem de dentro do arquivo é entrada hostil: nome de membro, tamanho declarado, quantidade de membros, tudo é conferido antes de virar memória, arquivo ou caminho no disco. Membro esparso é recusado antes de qualquer byte ser lido, e a recusa não custa nada, porque a biblioteca padrão do Python não sabe escrever esse formato, então nenhum arquivo gerado pelo próprio programa cai nela.
Na mesma linha, frase errada e arquivo alterado produzem uma mensagem só. Dizer "a frase estava certa, o arquivo é que está quebrado" seria dar um oráculo de graça a quem estragou o arquivo. E os nomes dos arquivos internos não aparecem no cifrado, porque estão dentro do tar: um .sns não revela que tem um servidor.pem lá dentro.
Instalar o núcleo sem confiar em ninguém no caminho
O age precisa estar na máquina, e "instale pelo seu gerenciador de pacotes" são três respostas diferentes para três sistemas, nenhuma das quais serve numa máquina sem administrador. O comando instalar-age baixa o pacote oficial da versão fixada no código, confere o SHA-256 contra uma tabela que mora dentro do próprio script, versionada, extrai só o binário e o deixa numa pasta ao lado. Se o hash não bater, nada é escrito no disco, nem parcialmente.
O que essa conferência promete, e nada além: espelho malicioso, proxy no meio do caminho ou conta comprometida no GitHub não trocam o binário sem que o hash mude. O que ela não cobre é o pacote já nascer comprometido na origem, e isso vale para o apt e para o brew também. Por isso a busca pelo age prefere, nesta ordem, o caminho que você apontar à mão, depois o do sistema, depois o da pasta local: o do sistema recebe atualização de segurança da distribuição, e o local fica na versão que você baixou até você baixar outra.
O que isso não protege, dito antes que alguém descubra
Em ferramenta de segurança, a lista do que não funciona é parte do produto. A minha, em ordem de importância:
- Máquina comprometida na hora em que você digita a frase. Keylogger vence qualquer formato de arquivo. Nada aqui ajuda.
- Swap em claro. RAM é paginável, e o kernel pode mandar páginas da sessão para o disco. O script lê a configuração de swap e avisa quando não consegue confirmar que ela é cifrada, em cada sistema do seu jeito. Avisar é tudo que código consegue fazer aqui; o resto é operacional.
- Área de transferência. Gerenciadores de histórico gravam em disco, o Windows sincroniza com a nuvem, o macOS manda o que você copiou para os outros aparelhos da conta pela rede, e em X11 qualquer programa do display lê a seleção sem pedir licença. O
--cliprecusa X11 sem um aceite explícito e avisa a cada uso nos outros. - Disco sem criptografia. Isto protege o arquivo cifrado, não a máquina. Restos de qualquer texto claro que já passou pelo disco podem sobreviver no journal. Criptografia de disco inteiro é a base que complementa isso, não que substitui. Foi a lição do notebook.
- O que você copia para fora da sessão volta a ser texto claro. É inerente ao uso.
- Encerrar a sessão é melhor esforço, não contenção. Um processo que saia do grupo sobrevive. Contenção de verdade exigiria um cgroup por sessão.
- O tamanho do conteúdo vaza. O arredondamento do tar embaralha os tamanhos pequenos por acidente, não por projeto. Padding é possível e não está implementado.
- Coerção e cold boot. Não há volume oculto nem negação plausível. E RAM retém dados por segundos a minutos depois de desligar; contra adversário com acesso físico imediato, desligue a máquina, não suspenda.
As camadas de macOS e de Windows foram escritas a partir da documentação e testadas contra atores de mentira no caminho, programas falsos que anotam como foram chamados e respondem como os de verdade responderiam. O modo de falha esperado é "não abre", nunca "abre no lugar errado", porque toda falha recusa em vez de cair para o disco. Mas nenhuma das duas rodou num Mac ou num Windows de verdade pelas minhas mãos, e a contribuição mais valiosa agora é alguém rodar o doutor lá e me contar.
O que mudou na minha rotina
Os arquivos .env dos projetos que não estão em uso vivem cifrados, e os que estão em uso são abertos pela sessão quando preciso ler uma credencial. As chaves de servidor ficam numa pasta cifrada que vai para o backup na nuvem sem medo, porque o arquivo cifrado pode ir para qualquer lugar; a frase está no papel, em dois lugares. E quando uma máquina precisa sair da minha mão, por manutenção, por empréstimo ou por venda, o que sai com ela não me tira o sono. A criptografia de disco inteiro cuida da máquina; o SNS cuida dos segredos que precisam sobreviver fora dela.
Resumo da conversa
- Nasceu de um notebook indo para a assistência sem disco cifrado. Hoje o disco é cifrado, e a ferramenta ficou para os segredos que precisam viver fora da máquina.
- A decisão central é não implementar cifra: o núcleo é o age, auditado, e a frase nunca entra no processo Python. Isso fechou por construção dois problemas que não se resolvem em Python.
- Oito palavras BIP-39 no papel são 88 bits, uma conta e não uma estimativa, e com scrypt por tentativa não fecham em orçamento nenhum.
- Abrir nunca grava: pager, área de transferência ou sessão em RAM, com histórico desligado e grupo de processos morto no fim. Cada sistema promete uma coisa diferente, e o
doutordiz qual. - O arquivo é adversário: tudo que vem de dentro dele é entrada hostil. E a lista do que a ferramenta não protege é parte do produto.
Igor Nascimento