Autenticando e assinando commits no GitHub com YubiKey no macOS, sem GPG

Chave SSH em arquivo no disco é um segredo copiável. Qualquer processo rodando com seu usuário consegue ler ~/.ssh/id_ed25519, e a partir daí faz push no seu nome em qualquer repositório onde você tem acesso. Backup do disco, sincronização acidental para a nuvem, malware: em todos esses casos o segredo sai da máquina sem você perceber.
Uma YubiKey resolve isso de forma diferente de como se costuma imaginar. Ela não guarda o arquivo da chave em um cofre: a chave privada é gerada dentro do token e nunca sai de lá. O que fica no seu disco é apenas uma referência. Sem o dispositivo físico plugado, esse arquivo não serve para nada.
Este guia configura uma única chave que faz as duas coisas, autenticação (clone e push) e assinatura de commits, usando FIDO2 puro. Sem GPG, sem agente de smartcard, sem PIN digitado no teclado. Só o toque no sensor.
Substitua voce@exemplo.com pelo seu e-mail verificado no GitHub em todos os comandos.
Por que FIDO2 e não GPG
O caminho tradicional para commits assinados é GPG, e há muito material sobre usar a applet OpenPGP da YubiKey para isso. Funciona, mas traz uma pilha considerável: gpg-agent, encaminhamento de socket, pinentry, chaveiro de confiança, sub-chaves, expiração, e o eterno “por que o agente parou de responder”.
O Git 2.34 trouxe uma alternativa: gpg.format ssh. A partir dele, o Git assina commits com uma chave SSH em vez de uma chave GPG. Como a YubiKey já sabe ser uma chave SSH via FIDO2 (o tipo ed25519-sk), a mesma chave serve para autenticar e para assinar. Uma chave, um registro, zero daemons.
A troca é que você perde algumas coisas que só o GPG faz: cadeia de confiança entre pessoas, criptografia de arquivos, revogação distribuída. Para o caso de uso “quero que meus commits apareçam como verificados no GitHub e meu push exija presença física”, nada disso é necessário.
sequenceDiagram
actor U as Você
participant G as git / ssh
participant Y as YubiKey (FIDO2)
participant H as GitHub
U->>G: git push
G->>G: lê o key handle em ~/.ssh
Note over G: handle não é chave privada,<br/>é só um ponteiro para a credencial
G->>Y: pede assinatura do desafio
Y-->>U: LED pisca
U->>Y: toque físico
Y->>Y: assina com a chave privada interna
Note over Y: chave privada nunca sai do token
Y-->>G: assinatura
G->>H: autentica com a assinatura
H-->>G: ok
Pré-requisitos
- Uma YubiKey com FIDO2 e firmware 5.2.3 ou superior. Vale tanto a 5 Series quanto a Security Key Series, que é FIDO-only; antes do 5.2.3 a applet FIDO2 não faz Ed25519
- macOS com Homebrew
- Git 2.34 ou superior, para assinatura via SSH
O firmware é gravado de fábrica e não é atualizável. Se o seu estiver abaixo de 5.2.3, dá para seguir o guia trocando ed25519-sk por ecdsa-sk em todos os comandos. A verificação do modelo e da versão vem no fim do passo 1, porque a ferramenta que reporta as duas é instalada lá.
1. Instalação
Aqui está a primeira pedra no caminho: o OpenSSH que a Apple distribui no macOS não inclui o middleware FIDO2. O ssh-keygen -t ed25519-sk simplesmente falha, com uma mensagem que não explica o motivo. É preciso usar o build do Homebrew.
brew install openssh libfido2 ykman gh
echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zshrc
exec zsh
Em Macs Intel o prefixo do Homebrew é /usr/local/bin em vez de /opt/homebrew/bin.
O que cada pacote faz aqui: openssh é o build com suporte a FIDO2, libfido2 é a biblioteca que conversa com o token via USB, ykman é a CLI de gerenciamento da YubiKey (usada no passo 2 e no diagnóstico) e gh é a CLI do GitHub, opcional mas conveniente para registrar as chaves sem abrir o navegador.
Confirme que o binário certo está sendo resolvido:
which -a ssh ssh-keygen
ssh -V
O caminho do Homebrew precisa aparecer antes de /usr/bin, e a versão não deve mencionar “Apple”. Se mencionar, o PATH não foi aplicado nesta sessão do shell.
Qual YubiKey você tem?
Qualquer YubiKey com FIDO2 serve para este guia. O que muda entre as linhas é só o passo 2, que endurece applets que nem toda chave tem:
ykman info
Na tabela Applications, Not available é hardware: não há comando que habilite.
ykman info mostra | Linha | O que muda |
|---|---|---|
FIDO2, PIV, OpenPGP, OATH e Yubico OTP Enabled | YubiKey 5 Series (5 NFC, 5C, 5 Nano, 5C NFC…) | Nada. Sem NFC no modelo, omita o ykman config nfc --disable OTP do passo 2 |
FIDO2 e FIDO U2F Enabled, o resto Not available | Security Key Series (Security Key NFC, C NFC) | No passo 2, faça só o PIN do FIDO2; PIV e OTP não existem aqui |
| Sem FIDO2, só FIDO U2F | YubiKey 4 / 4C / NEO | Não serve: sem CTAP2 não há -O resident |
As duas primeiras servem igual como chave principal: aqui uma Security Key faz tudo que uma 5 Series faz. A 5 Series só pesa se você usa OATH, PIV ou OpenPGP, que uma reserva FIDO-only não devolve.
Ed25519 na applet FIDO2 chegou no firmware 5.2.3. Abaixo disso, troque ed25519-sk por ecdsa-sk em todos os comandos; unidades compradas hoje vêm com 5.7.x.
Uma Security Key C NFC, para comparação. Repare que não há linha Serial number:
Device type: Security Key C NFC
Firmware version: 5.7.4
Form factor: Keychain (USB-C)
Enabled USB interfaces: FIDO
NFC transport is enabled
Applications USB NFC
Yubico OTP Not available Not available
FIDO U2F Enabled Enabled
FIDO2 Enabled Enabled
OATH Not available Not available
PIV Not available Not available
OpenPGP Not available Not available
YubiHSM Auth Not available Not available
Security Key Series fora da edição Enterprise não tem número de série. Se a sua mostra serial e PIV Enabled, é uma 5 Series.
Para quem prefere interface gráfica, o Yubico Authenticator (versão 7 ou superior) faz as mesmas operações do ykman e é útil para visualizar o que está armazenado no dispositivo. Os comandos deste guia assumem a CLI.
2. Preparando um YubiKey novo
Se o dispositivo acabou de sair da caixa, vale gastar dez minutos endurecendo a configuração antes de gerar qualquer chave. As credenciais de fábrica são públicas e documentadas.
Se a sua YubiKey já está em uso e configurada, pule para o passo 3.
O YubiKey tem PINs independentes por applet, e só o primeiro abaixo vale para todos os modelos.
Defina o PIN do FIDO2
É o único item deste passo que existe em qualquer YubiKey com FIDO2, e o único que este guia de fato usa: é o PIN que sites como GitHub, Google e AWS pedem em passkeys, e o que protege as credenciais residentes do passo 3.
ykman fido access change-pin
O mesmo comando serve para definir e para trocar. Chave nova não vem com PIN de fábrica: até você rodar isso, não há PIN nenhum, e sem PIN não dá para criar credencial residente nem listar o que está no dispositivo.
O PIN aceita a partir de 4 caracteres, alfanuméricos; use de 6 a 8 dígitos. Quatro é pouco contra um contador de 8 tentativas, e esgotar as 8 bloqueia a applet, e destravar exige ykman fido reset, que apaga todas as credenciais residentes do dispositivo.
Troque as credenciais padrão do PIV
Só na 5 Series. Na Security Key Series não existe applet PIV, e os comandos abaixo falham por não ter com o que conversar.
O PIV é o applet de smart card, relevante se você for usar certificados ou SSH via PIV. As três credenciais de fábrica precisam ser trocadas:
ykman piv access change-management-key --generate --protect
ykman piv access change-puk
ykman piv access change-pin
A Management Key de fábrica é uma constante conhecida; --generate --protect gera uma nova e a guarda protegida pelo PIN, então você não precisa memorizá-la. O PUK padrão é 12345678 e o PIN padrão é 123456.
Este guia usa FIDO2, não PIV. Se você não pretende usar PIV, tecnicamente pode pular esses três comandos. Mas deixar as credenciais de fábrica em um applet que você não usa também não é ideal: alguém com acesso físico ao dispositivo poderia provisionar certificados ali.
Reduza a superfície de ataque
Também só na 5 Series. A Security Key Series não tem Yubico OTP para desativar.
Desative o Yubico OTP. É o protocolo antigo, em que o toque no sensor digita uma string de 44 caracteres onde estiver o cursor. Se você não usa, ele só serve para produzir texto aleatório dentro do seu editor quando você toca na chave sem querer:
ykman config usb --disable OTP
ykman config nfc --disable OTP
Se você nunca vai aproximar a chave de um celular, desativar o NFC por completo elimina a possibilidade de uso por proximidade sem que você perceba.
3. Geração da chave
ssh-keygen -t ed25519-sk \
-O resident \
-O application=ssh:github \
-C "voce@exemplo.com" \
-f ~/.ssh/id_ed25519_sk_github \
-N ""
Toque na YubiKey quando o LED piscar.
O que cada opção faz:
| Opção | Efeito |
|---|---|
-O resident | A credencial fica armazenada dentro da YubiKey, permitindo recuperá-la em uma máquina nova com ssh-keygen -K. Consome um slot (25 até o firmware 5.6.x, 100 a partir do 5.7) |
-O application=ssh:github | Rótulo da credencial no token. Precisa começar com ssh:. É o que aparece em ykman fido credentials list |
-N "" | Sem passphrase no arquivo de handle |
Uma opção que não está aí, de propósito: -O verify-required. Ela exige o PIN a cada operação e, em várias combinações de firmware e libfido2, quebra a assinatura ed25519-sk com o erro Couldn't sign message: invalid format. Se você quer PIN como segundo fator mesmo assim, use ecdsa-sk, que não sofre desse problema.
Vale repetir, porque é o ponto que mais confunde: o arquivo ~/.ssh/id_ed25519_sk_github não é uma chave privada. É um key handle, um ponteiro para a credencial dentro do token. Vazá-lo não expõe nada.
Teste a assinatura antes de seguir adiante:
echo teste | ssh-keygen -Y sign -f ~/.ssh/id_ed25519_sk_github -n teste
Deve pedir toque e imprimir um bloco BEGIN SSH SIGNATURE. Se falhar aqui, resolva antes de continuar; a seção de diagnóstico cobre os casos comuns.
4. Registro no GitHub
Um detalhe que passa batido: a mesma chave pública precisa ser registrada duas vezes, com tipos diferentes. O GitHub trata autenticação e assinatura como capacidades separadas. Registrada só como Authentication, seus commits ficam “Unverified”; registrada só como Signing, o push é recusado.
pbcopy < ~/.ssh/id_ed25519_sk_github.pub
Em https://github.com/settings/keys:
- New SSH key → Key type: Authentication Key → cole → Add
- New SSH key → Key type: Signing Key → cole a mesma → Add
Ou pela CLI:
gh auth login
gh ssh-key add ~/.ssh/id_ed25519_sk_github.pub --type authentication --title "YubiKey"
gh ssh-key add ~/.ssh/id_ed25519_sk_github.pub --type signing --title "YubiKey signing"
gh ssh-key list
5. Configuração do SSH
Em ~/.ssh/config:
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519_sk_github
IdentitiesOnly yes
O IdentitiesOnly yes não é enfeite. Sem ele, o ssh oferece ao servidor as chaves do agente e os arquivos de identidade padrão do ~/.ssh, uma por uma, mesmo tendo um IdentityFile explícito. Se você tem várias, o GitHub corta a conexão por excesso de tentativas antes de chegar na certa, e o erro que aparece é um genérico Too many authentication failures.
Opcionalmente, force SSH mesmo em remotes que já estão em HTTPS:
git config --global url."git@github.com:".insteadOf "https://github.com/"
6. Assinatura de commits
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519_sk_github.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
printf '%s %s\n' "voce@exemplo.com" "$(cat ~/.ssh/id_ed25519_sk_github.pub)" > ~/.ssh/allowed_signers
Dois erros fáceis aqui. O primeiro campo tem que ser o e-mail real do committer, o mesmo do
user.email. Deixando o placeholder, a verificação local não falha; ela passa. O git acha o principal pela chave, não pelo e-mail do commit, e você fica validando assinaturas contra uma identidade que não é sua.E o
>sobrescreve o arquivo. Ao adicionar a segunda chave no passo 8, use>>. Repetir este bloco apaga a linha da primeira.
Note que user.signingkey aponta para a chave pública. Isso parece errado à primeira vista, mas é assim que o Git funciona no modo SSH: ele usa o .pub para localizar a credencial correspondente, e a assinatura em si acontece dentro do token.
O allowed_signers serve apenas para verificação local: é o que faz git log --show-signature dizer “Good signature” em vez de “No principal matched”. O GitHub não lê esse arquivo; ele usa o registro do passo 4, e o badge “Unverified” lá tem outra causa (passo 10).
7. Validação
ssh -T git@github.com
Esperado, após o toque: Hi <usuario>! You've successfully authenticated...
cd /tmp && git init teste-yk && cd teste-yk
git commit --allow-empty -m "test: yubikey signing"
git log --show-signature -1
Esperado: Good "git" signature for voce@exemplo.com.
Limpe depois:
cd /tmp && rm -rf teste-yk
8. Backup e recuperação
Este é o ponto do guia que merece mais atenção, porque a propriedade que torna a YubiKey segura é a mesma que torna a perda irreversível.
A chave privada não é exportável. Não existe backup dela. Nenhum. YubiKey perdida, quebrada ou resetada significa acesso perdido, e nada recupera aquela chave: nem suporte da Yubico, nem root na sua máquina, nem o GitHub.
Segunda YubiKey (faça agora, não depois)
A única recuperação possível é uma segunda chave configurada em paralelo. Repita os passos 3 e 4 mudando o nome do arquivo e o application id:
ssh-keygen -t ed25519-sk \
-O resident \
-O application=ssh:github-backup \
-C "voce@exemplo.com (backup)" \
-f ~/.ssh/id_ed25519_sk_github_backup \
-N ""
O application id diferente importa porque o ssh-keygen -K deriva o nome do arquivo dele: duas credenciais ssh:github colidem ao recuperar numa máquina nova.
Registre a pública nos dois tipos e acrescente a linha ao allowed_signers com >>, já que > apagaria a da primeira:
printf '%s %s\n' "voce@exemplo.com" "$(cat ~/.ssh/id_ed25519_sk_github_backup.pub)" >> ~/.ssh/allowed_signers
Uma linha por chave, mesmo e-mail nas duas:
voce@exemplo.com sk-ssh-ed25519@openssh.com AAAAGnNr...principal... voce@exemplo.com
voce@exemplo.com sk-ssh-ed25519@openssh.com AAAAGnNr...backup... voce@exemplo.com (backup)
No ~/.ssh/config, a do dia a dia primeiro:
Host github.com
User git
IdentityFile ~/.ssh/id_ed25519_sk_github
IdentityFile ~/.ssh/id_ed25519_sk_github_backup
IdentitiesOnly yes
Com a principal desplugada, o ssh tenta a chave dela e falha com signing failed for ED25519-SK ...: device not found antes de passar para a próxima IdentityFile. É esperado.
Já o user.signingkey aceita uma chave por vez, sem fallback. Duas funções no ~/.zshrc:
yk-main() { git config --global user.signingkey ~/.ssh/id_ed25519_sk_github.pub; echo "signing: principal"; }
yk-backup() { git config --global user.signingkey ~/.ssh/id_ed25519_sk_github_backup.pub; echo "signing: backup"; }
Guarde a reserva em local separado da primeira, e os recovery codes do 2FA junto. Quantas chaves ter e como testá-las estão em Estratégia de backup de YubiKey.
Máquina nova
Como a credencial é residente, a YubiKey basta, não é preciso copiar nada da máquina antiga:
cd ~/.ssh && ssh-keygen -K
O nome do arquivo vem do application id sem o prefixo ssh:, então ssh:github gera id_ed25519_sk_rk_github. Renomeie e refaça os passos 5 e 6:
mv id_ed25519_sk_rk_github id_ed25519_sk_github
mv id_ed25519_sk_rk_github.pub id_ed25519_sk_github.pub
9. Descobrindo qual credencial é qual
Cenário comum: a YubiKey já foi configurada antes, por você em outra máquina ou para outra conta, e agora você não sabe o que tem dentro dela. Ou você tem duas contas, pessoal e de trabalho, e precisa saber qual credencial pertence a qual.
Identifique antes de mexer em qualquer coisa. Apagar a credencial errada é irreversível.
1. Descarte GPG e PIV como possibilidade
gpg --card-status # "General key info: [none]" = nenhuma chave OpenPGP no cartão
ykman piv info # sem CHUID e management key ainda no default = nenhum certificado PIV
Se os dois vierem vazios, a autenticação é FIDO2 puro (-sk), não GPG smartcard nem PIV. Isso já elimina metade dos caminhos de investigação. Numa Security Key Series os dois comandos falham por falta do applet, o que responde a pergunta do mesmo jeito: só pode ser FIDO2.
2. Liste as credenciais residentes na FIDO2
ykman fido credentials list
Pede o PIN, então rode em um terminal interativo de verdade, não via automação ou CI. Cada linha traz Credential ID, RP ID, Username e Display name.
As credenciais SSH aparecem com RP ID no formato ssh:<nome>, exatamente o valor que foi passado em -O application= na criação, como ssh:github ou ssh:github-trabalho. Linhas com RP ID do tipo github.com, google.com ou login.microsoft.com são passkeys de login pelo navegador (WebAuthn), não chaves SSH. Ignore-as para este fim.
3. Extraia as chaves públicas das credenciais SSH
cd ~/.ssh && ssh-keygen -K
Gera um par id_ed25519_sk_rk_<nome> e .pub para cada credencial ssh:* encontrada. O prefixo ssh: é cortado do nome do arquivo, então ssh:github vira id_ed25519_sk_rk_github.
4. Compare o fingerprint com o que está cadastrado em cada conta
ssh-keygen -lf ~/.ssh/id_ed25519_sk_rk_github.pub
Confronte com o fingerprint SHA256 listado em https://github.com/settings/keys, trocando de conta se você tiver mais de uma. Ou teste direto, apontando para o arquivo específico:
ssh -T git@github.com -i ~/.ssh/id_ed25519_sk_rk_github
O toque seguido de Hi <usuario>! You've successfully authenticated confirma qual credencial pertence a qual conta, sem ambiguidade.
5. Normalize o nome e siga o guia
Renomeie o arquivo recuperado para o padrão usado aqui (id_ed25519_sk_github) e siga os passos 5 e 6.
10. Diagnóstico de erros
As mensagens do OpenSSH nesse fluxo são particularmente ruins, e algumas dizem coisas que não têm relação com o problema real. Vale conhecer as principais.
no FIDO SecurityKeyProvider specified
Você está usando o ssh-keygen da Apple. Volte ao passo 1 e corrija o PATH.
Couldn't sign message: invalid format
O token respondeu, mas o OpenSSH não conseguiu interpretar a resposta. Quase sempre é -O verify-required combinado com ed25519-sk.
Isole o problema com uma chave descartável (sem -O resident, então não consome slot):
ssh-keygen -t ed25519-sk -f /tmp/tk -N ""
echo teste | ssh-keygen -Y sign -f /tmp/tk -n teste
rm -f /tmp/tk /tmp/tk.pub
- Funcionou → o problema está em alguma das opções que você usou, provavelmente
verify-required. Regenere sem ela. - Falhou → o problema é de binário, libfido2 ou firmware. Rode
brew reinstall libfido2 openssh, façaunset SSH_SK_PROVIDER, e se persistir troque paraecdsa-sk.
incorrect passphrase supplied to decrypt private key
Mensagem enganosa. O OpenSSH reutiliza esse texto para qualquer falha FIDO2, e ela não tem relação nenhuma com passphrase de arquivo. As causas reais são PIN errado, toque que expirou, ou key handle que não corresponde à credencial no token.
Antes de tentar de novo, verifique o contador:
ykman fido info
A linha PIN traz o contador, no formato PIN: 8 attempt(s) remaining. Abaixo de 8 confirma que o PIN está sendo rejeitado. Se vier PIN is temporarily blocked, foram três erros seguidos no mesmo ciclo de energia: desplugue e replugue a chave para liberar as tentativas restantes.
Atenção: o PIN do FIDO2 tem 8 tentativas. Esgotadas, a applet bloqueia e o único caminho de volta é
ykman fido reset, que apaga permanentemente todas as passkeys de todas as contas armazenadas no dispositivo, não só a do GitHub. Diagnostique a causa antes de tentar repetidamente.
Permission denied (publickey)
A chave não está registrada como Authentication Key no GitHub, ou o IdentityFile aponta para o arquivo errado.
gh ssh-key list
ssh -vvv -T git@github.com 2>&1 | grep -E "Offering|Authentications that can continue"
O Offering mostra qual arquivo o ssh está de fato tentando usar.
Handle não corresponde à credencial
O ykman fido credentials list mostra Credential ID, RP ID e nomes, nunca o fingerprint SSH. Não dá para cruzar as duas coisas por ali. Para saber se o handle no disco corresponde a alguma credencial no token, recupere as residentes num diretório separado e compare os fingerprints:
ssh-keygen -lf ~/.ssh/id_ed25519_sk_github.pub
mkdir -p /tmp/rk && cd /tmp/rk && ssh-keygen -K
for f in *.pub; do ssh-keygen -lf "$f"; done
Se o SHA256: do primeiro comando aparecer na saída do segundo, o handle é válido e o problema está em outro lugar, e você pode apagar o diretório temporário com rm -rf /tmp/rk. Se não aparecer, o arquivo é resquício de uma chave antiga, talvez de um dispositivo que foi resetado: use o par recuperado em /tmp/rk no lugar dele, renomeando conforme o passo 8.
O caso inverso, credencial ocupando slot no token sem handle correspondente no disco, se resolve apagando pelo Credential ID que o credentials list mostra:
ykman fido credentials delete <credential-id>
Use sempre credentials delete, que remove uma credencial específica. Nunca ykman fido reset, que apaga tudo.
Commits aparecem “Unverified” no GitHub
Três causas, em ordem de frequência:
- A chave não foi registrada com o tipo Signing Key (o passo que se esquece)
- O
user.emaildo Git é diferente do e-mail verificado na conta GitHub - Falta o
git config --global gpg.format ssh
11. Modelo de segurança
Vale ser explícito sobre o que essa configuração protege e o que não protege. Sem verify-required, o fator de autenticação é presença física apenas:
- ✅ Malware remoto não consegue usar a chave: precisa de um toque no dispositivo, e não há como simular isso por software
- ✅ A chave privada não é extraível, nem com root na máquina
- ✅ Vazamento de backup do disco não expõe nada, porque o arquivo é só um handle
- ❌ Quem tiver a YubiKey em mãos consegue usar a chave
Esse último item é a consequência direta de não exigir PIN. Na prática significa: não deixe a YubiKey plugada em máquina desacompanhada. Se o seu ambiente não permite confiar nisso (máquina compartilhada, escritório aberto, laptop que viaja), use -O verify-required com ecdsa-sk para adicionar o PIN como segundo fator, aceitando digitar o PIN a cada operação.
