Ir para o conteúdo

Blogue ·

Como uma atualização assinada se recusa

Uma soma de verificação prova que a descarga chegou intacta, não que foi enviada por si. O que custa mesmo assinar um agente que se atualiza sozinho.

Por Provibr

Qualquer software que se atualiza a si próprio é execução remota de código que foi convidada a entrar. Isso não é um argumento contra as atualizações automáticas, que são a única forma realista de evitar que uma frota de agentes em hardware de clientes apodreça. É um argumento a favor de ser preciso quanto ao que o mecanismo de atualização promete, porque a maioria deles promete menos do que a respetiva documentação dá a entender.

Corremos um agente em servidores de outras pessoas. Fala com a nossa plataforma, aplica comandos ao hipervisor dessas pessoas e substitui o seu próprio binário quando publicamos uma versão nova. Foi isto que tivemos de construir antes de estarmos dispostos a deixá-lo fazer isso.

Uma soma de verificação responde à pergunta errada

Quase todas as ferramentas que se atualizam a si próprias publicam um SHA-256 ao lado da descarga, e muitas ficam-se por aí. Uma soma de verificação é útil, e não faz o trabalho que as pessoas julgam que faz.

ControloO que prova de factoO que não consegue ver
TLS na descargaOs bytes vieram daquele anfitrião, por aquela ligaçãoNada sobre quem os produziu
SHA-256 publicado ao lado do ficheiroA descarga não está truncada nem corrompidaQuem puder alterar o ficheiro também altera o número
Assinatura sobre os bytesEstes bytes foram produzidos por quem detém uma chave privadaSe essa chave ainda lhe pertence

A linha do meio é o problema inteiro. A soma de verificação e o artefacto vivem normalmente no mesmo sítio e são normalmente graváveis pelo mesmo processo. Se um atacante conseguir substituir um, substitui o outro, e a verificação passa com entusiasmo.

Portanto, a soma de verificação é um teste prévio barato e explicitamente não é a fronteira de confiança. Apanha uma descarga truncada numa única passagem por bytes que já estávamos a transmitir. A âncora é a assinatura.

Onde tem de viver a chave pública

Eis a primeira decisão que determina se alguma coisa disto é real: a chave de verificação não pode ser um ficheiro que viaja ao lado do binário, porque então comprometer o caminho de entrega compromete também a chave.

O nosso agente recebe a chave pública Ed25519 da plataforma compilada dentro de si, a partir de uma variável de ambiente de compilação. Numa compilação de lançamento não há outra origem. Numa compilação de depuração, quem programa pode apontá-la a uma chave local com uma variável de ambiente, e esse caminho é totalmente removido da compilação por um atributo cfg, porque uma chave que pode ser sobreposta em tempo de execução não é uma âncora mas uma sugestão.

Há dois pormenores em redor disto que importam mais do que parecem.

O script de compilação declara que tem de correr de novo quando essa variável de ambiente mudar. Sem essa linha, uma compilação de lançamento que partilhe uma cache de destino com uma compilação anterior reutiliza alegremente a chave dessa compilação, e ninguém repara até aquilo chegar a um cliente.

E quem interpreta a chave é rigoroso quanto à codificação, e não quanto ao comprimento. Uma chave pública Ed25519 em formato SPKI é um prefixo fixo de 12 bytes seguido de 32 bytes; uma chave X25519 também tem 32 bytes, com um identificador de algoritmo diferente. Aceitar qualquer coisa com 32 bytes de comprimento aceitaria uma chave de outro algoritmo e falharia mais tarde, nalgum sítio que não faz sentido nenhum.

Recusar antes de descarregar

Quando a plataforma diz ao agente para se atualizar, o agente percorre a lista completa de condições prévias antes de obter um único byte:

  1. Existe na diretiva um SHA-256 utilizável?
  2. Existe um endereço de descarga?
  3. Existe uma assinatura, e tem exatamente 64 bytes?
  4. Já estamos a correr esta versão?
  5. Existe neste anfitrião o script de aplicação privilegiado?
  6. Existe o ficheiro local da âncora de confiança?
  7. Este binário tem sequer uma chave da plataforma compilada dentro de si?

O terceiro passo merece atenção. Uma plataforma mais antiga que não preencha o campo da assinatura deve falhar aqui, de forma ruidosa, em vez de recorrer em silêncio à verificação mais fraca que fazíamos antes. Verificação opcional não é verificação. Tornar o campo em falta um erro fatal é a diferença entre um mecanismo e um hábito.

A ordem dos três últimos também é deliberada. As mensagens do género «falta o passo privilegiado no seu anfitrião, execute o instalador outra vez» são mais acionáveis do que «este binário não tem chave da plataforma», por isso vêm primeiro.

Assinar os bytes, não uma descrição deles

A assinatura cobre o artefacto exatamente como ele aterra no disco. Ed25519 puro, sem resumo prévio, sem envelope canónico, sem metadados incluídos. A razão não é purismo. É que duas implementações completamente diferentes têm de chegar ao mesmo veredicto.

No agente é ed25519-dalek com verify_strict, que recusa assinaturas que envolvam componentes de torção. Com uma chave e um ficheiro deveria existir exatamente uma assinatura válida, e a variante não estrita não garante isso.

No anfitrião, o script privilegiado que instala de facto a atualização verifica os mesmos bytes com openssl pkeyutl -verify -pubin -rawin. Mesma chave, mesmos bytes, mesma resposta. Se tivéssemos feito um resumo prévio, ou assinado um manifesto em vez do artefacto, a verificação ao nível da linha de comandos teria de reimplementar o nosso enquadramento, e as duas verificações afastar-se-iam uma da outra.

É também a verificação que corremos à mão no servidor depois de cada lançamento, contra a chave pública de produção, antes de acreditarmos que houve um lançamento.

O manifesto ao lado do artefacto transporta a versão, o identificador de compilação, a soma de verificação, o tamanho, a data do lançamento, o tipo de artefacto e a assinatura. São metadados para o painel, e nada disso é de confiança: a assinatura que lá está é sobre o binário, não sobre o manifesto.

O agente não consegue instalar a sua própria atualização

O agente corre como utilizador sem privilégios. Não consegue escrever no diretório onde vive o seu próprio binário, e não deveria conseguir.

Por isso prepara. Escreve primeiro a assinatura e a soma de verificação num diretório de preparação, e só então renomeia o binário verificado para o lugar, ao lado delas, porque o script privilegiado usa a presença desse binário como sinal de partida e os outros dois ficheiros têm de já lá estar. Depois termina de forma limpa, e o gestor de serviços reinicia-o, executando primeiro um passo privilegiado de pré-arranque.

Vale a pena descrever esse script, porque os seus pressupostos são o contrário do que se poderia supor.

Termina sempre com zero. Um passo de pré-arranque que falhasse impediria o serviço de arrancar de todo, e uma atualização defeituosa que deixa um cliente offline é muito pior do que uma atualização defeituosa que simplesmente não se aplica.

O diretório de preparação não é de confiança. Tudo o que lá está foi escrito pelo utilizador sem privilégios do agente: o binário, a soma de verificação, a assinatura. Quem tomar conta desse utilizador consegue substituir os três. É por isso que a assinatura, verificada com a chave do lado privilegiado, é a única coisa que decide.

Copia antes de verificar. Os bytes preparados são copiados exatamente uma vez para um ficheiro temporário novo, pertencente ao root, ao lado do destino, e todas as verificações seguintes leem a cópia. O ficheiro que é verificado é o mesmo inode que é renomeado para o lugar, pelo que não existe uma janela em que um ficheiro diferente possa ser trocado entre a verificação e a instalação.

O resto é deliberadamente aborrecido: recusar ligações simbólicas, exigir que a soma de verificação tenha exatamente 64 caracteres hexadecimais, exigir que o ficheiro comece pelo número mágico ELF, alargar as permissões para executável só depois de a assinatura passar, iniciá-lo uma vez como utilizador do serviço em vez de como root, e só então renomeá-lo por cima do binário em funcionamento. Uma assinatura diz de onde vieram os bytes. Não diz que eles merecem root.

A versão que assinámos com a chave errada

O mecanismo acima funciona. Ainda assim publicámos uma versão defeituosa, e a forma como falhou é a parte útil.

O nosso script de compilação recebia um sinalizador para «isto é uma versão de produção». Na altura, esse sinalizador governava apenas a publicação, e não a chave usada para assinar. Uma versão construída sem nomear também a chave de produção no servidor foi assinada com a chave de desenvolvimento, tinha a chave de licenciamento de desenvolvimento compilada dentro de si, e foi depois verificada localmente contra essa mesma chave de desenvolvimento. A verificação disse que o artefacto estava bem, porque era internamente coerente. Só não era nosso.

Todos os agentes reais recusaram a atualização, corretamente, porque a assinatura não correspondia à chave compilada dentro de si. O mecanismo fez exatamente aquilo para que foi construído. O erro estava a montante dele.

Daí saíram duas salvaguardas, ambas em código e não num documento. Construir para produção sem nomear a chave de produção passou a ser uma falha fatal que interrompe o script. E a chave de assinatura e a chave de verificação têm de estar na mesma máquina: verificar uma assinatura com uma chave obtida noutro sítio não prova absolutamente nada.

Vale a pena enunciar a lição geral com clareza. Um passo de verificação que recebe as duas metades de um par correspondente vindas do ambiente errado passa, e passa com confiança. Os esquemas de assinatura protegem contra atacantes. Não protegem contra si próprio.

Uma licença não é um binário, e ambos são assinados

Assinamos duas coisas diferentes com a mesma chave: binários do agente e ficheiros de licença. Isso é um perigo, porque uma assinatura sobre um deles nunca deve ser aceite como assinatura sobre o outro.

A separação é estrutural, e não uma convenção de nomes. Um ficheiro de licença assina um envelope de texto construído a partir do identificador do formato, do sal, do nonce e do texto cifrado, unidos por mudanças de linha. Um binário assina bytes ELF em bruto. O script de instalação procura o número mágico ELF antes de verificar seja o que for, pelo que uma assinatura de licença válida nunca pode ser apresentada como uma assinatura de binário válida.

O fluxo da licença tem mais uma propriedade que vale a pena roubar: verifica a assinatura antes de derivar a chave de decifração. O Argon2id com um custo de memória de 64 MiB é caro por desenho, e fazer esse trabalho para um ficheiro que nunca foi nosso é uma negação de serviço gratuita. As verificações baratas de autenticidade pertencem antes da criptografia cara.

O que isto não compra

Assinar liga um artefacto a uma chave. Não diz nada sobre se essa chave continua a ser sua, nem sobre se a versão que assinou presta. Se a nossa chave de assinatura fugir, todos os agentes aceitam aquilo que o atacante construir, e rodá-la significa reconstruir todos os binários e substituir a âncora de confiança em todos os anfitriões, porque a chave está compilada lá dentro. Esse é o preço de a chave ser uma âncora em vez de uma sugestão, e é a troca certa, mas é um custo real e pertence ao plano, e não a uma nota de rodapé.

É assim que funciona a atualização do agente no Provibr, onde o agente corre no hardware do próprio cliente, só se liga para fora e mantém as credenciais do hipervisor nesse anfitrião.

Comece com um host e um agente

Crie uma conta, instale o agente com um único comando e ligue o seu primeiro host Proxmox. O período experimental dura 14 dias e não pede quaisquer dados de pagamento.