>_ josetilabs.com
← blog

13 out 2026 · segurança, linux, ssh · 5 min

Trocar a senha do servidor por chave SSH

Vinte senhas erradas contra um sshd de teste, todas recusadas, quatro segundos. A média por tentativa foi de 0,205 segundos e o servidor nunca aplicou atraso progressivo: 4,87 tentativas por segundo, sem castigo nenhum por errar. Quando desliguei PasswordAuthentication, o cliente deixou de receber prompt de senha e passou a recusar na hora com Permission denied (publickey). O caminho desapareceu da conversa inteira.

O que muda de verdade

A senha é o único jeito de autenticar que o atacante pode testar sem roubar nada do seu lado: ele lista as senhas mais comuns (o que a conta com 8,8 milhões de registros dinamarqueses mostrou) e vai. A chave não dá para testar, e o servidor compara uma assinatura, não uma palavra que o atacante possa chutar.

O detalhe que quase todo mundo deixa passar é o que o servidor anuncia antes de recusar. Com a senha ligada o cliente recebe Permission denied (publickey,password), e essa palavra no fim é um convite. Com a senha desligada a lista encurta para Permission denied (publickey). O daemon parou de oferecer o que o atacante queria.

Duas telas de terminal lado a lado: com a senha ligada o servidor recusa com Permission denied (publickey,password) e um lote de 20 tentativas erradas leva 4,107 segundos; com a senha desligada recusa com Permission denied (publickey) e o login por chave entra na hora
À esquerda, com a senha ligada: vinte senhas erradas gastam 4,107 segundos e o servidor nunca desacelera. À direita, com a senha desligada: a tentativa nem recebe prompt, e a chave entra.

O artefato

Quatro modos num script: gerar a chave, instalar num servidor, desligar a senha depois de provar que a chave entra, e um modo de simulação que mostra o que será escrito sem tocar em nada. O que interessa no último é a ordem em que as coisas acontecem. Provar a chave antes de desligar a senha evita o erro clássico de trancar a própria máquina para fora.

bash
#!/usr/bin/env bash
# ssh-chave.sh: chave SSH no lugar da senha, sem deixar a maquina trancada.
#
# Quatro modos:
#   gerar              cria ~/.ssh/id_ed25519 se ainda nao existir
#   instalar u@host    copia a chave publica e confere que ela entra
#   travar   u@host    desliga senha, so depois de provar que a chave entra
#   simular            mostra o que travar faz, sem tocar em nada
set -uo pipefail

SSH_DIR="${HOME}/.ssh"
CHAVE="${SSH_DIR}/id_ed25519"
DROPIN_GERAL="/etc/ssh/sshd_config.d/20-desliga-senha.conf"
DROPIN_ALVO="/etc/ssh/sshd_config"

falar() { printf '%s\n' "$*"; }
passo() { printf '\n== %s\n' "$*"; }

dependencias() {
  local faltando=()
  command -v ssh-keygen >/dev/null 2>&1 || faltando+=("ssh-keygen")
  command -v ssh >/dev/null 2>&1 || faltando+=("ssh")
  command -v ssh-copy-id >/dev/null 2>&1 || faltando+=("ssh-copy-id")
  if [ "${#faltando[@]}" -gt 0 ]; then
    falar "faltando nesta maquina: ${faltando[*]}"
    falar "no Ubuntu e no Debian: sudo apt-get install -y openssh-client openssh-server"
    return 1
  fi
  return 0
}

gerar_chave() {
  passo "gerando a chave"
  mkdir -p "$SSH_DIR" && chmod 700 "$SSH_DIR"
  if [ -f "$CHAVE" ]; then
    falar "a chave $CHAVE ja existe, mantida como esta"
  else
    ssh-keygen -t ed25519 -a 100 -N '' -C "$(id -un)@$(hostname -s 2>/dev/null || echo local)" -f "$CHAVE"
    falar "chave criada em $CHAVE"
  fi
  chmod 600 "$CHAVE"
  chmod 644 "${CHAVE}.pub"
  falar "impressao digital da publica:"
  ssh-keygen -lf "${CHAVE}.pub"
  falar "guarde a linha acima. E' a unica prova: so ela diz que a chave e' sua."
}

instalar_chave() {
  local alvo="${1:-}"
  [ -n "$alvo" ] || { falar "uso: $0 instalar usuario@host"; return 1; }
  [ -f "${CHAVE}.pub" ] || { falar "a chave publica nao existe ainda: rode $0 gerar"; return 1; }
  passo "instalando a chave publica em $alvo"
  ssh-copy-id -i "${CHAVE}.pub" "$alvo" || {
    falar "ssh-copy-id falhou. Se o servidor pedir senha e voce nao souber qual e', pare aqui."
    return 1
  }
  passo "conferindo que a chave entra sem digitar senha"
  if ssh -o BatchMode=yes -o ConnectTimeout=10 "$alvo" true 2>/dev/null; then
    falar "ok: a chave entra sozinha em $alvo"
  else
    falar "falhou: a chave nao entrou em $alvo. Nao siga para travar."
    return 1
  fi
  falar "no servidor, confira o que ele aceita:"
  falar "  ssh $alvo 'sudo sshd -T | grep -Ei \"^(passwordauthentication|pubkeyauthentication)\"'"
}

conteudo_regra() {
  cat <<'REGRA'
# gerado por ssh-chave.sh: senha fora, chave dentro.
# Vale o primeiro valor encontrado: em OpenSSH, para cada palavra-chave
# conta a primeira definicao que aparece, e Includedir vem antes da leitura do arquivo.
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
PermitEmptyPasswords no
MaxAuthTries 3
AllowTcpForwarding yes
AllowAgentForwarding no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
REGRA
}

simular() {
  passo "simulacao: nada e' alterado aqui"
  local trabalho config antes
  trabalho="$(mktemp -d "${TMPDIR:-/tmp}/ssh-chave-demo-XXXXXX")"
  if [ -r "$DROPIN_ALVO" ]; then
    config="$trabalho/sshd_config"
    cp "$DROPIN_ALVO" "$config"
    antes="o sshd_config do sistema"
  else
    config="$trabalho/sshd_config"
    {
      falar "# exemplo minimo, so o que um sshd costuma trazer de serie"
      falar "Port 22"
      falar "PasswordAuthentication yes"
      falar "PermitRootLogin yes"
      falar "PubkeyAuthentication yes"
      falar "X11Forwarding yes"
    } > "$config"
    antes="um sshd_config de exemplo (esta maquina nao tem sshd)"
  fi
  falar "partindo de: $antes"
  falar ""
  falar "--- $config (depois) ---"
  falar "$(cat "$config")"
  falar ""
  falar "--- regra que entra em sshd_config.d/20-desliga-senha.conf ---"
  conteudo_regra
  falar ""
  if command -v sshd >/dev/null 2>&1; then
    falar "--- sshd -T le a configuracao efetiva ---"
    sshd -T -f "$config" 2>&1 | grep -Ei "^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin)" || falar "(o teste acima devolveu nada: o arquivo de exemplo so serve para mostrar o formato)"
  else
    falar "sshd nao esta instalado aqui, entao a conferencia final fica para a sua maquina:"
    falar "  sudo sshd -t -f $config && sudo sshd -T | grep -Ei '^(passwordauthentication|pubkeyauthentication)'"
  fi
  falar ""
  falar "simulacao em $trabalho. Nada fora desse diretorio foi tocado."
}

travar() {
  local alvo="${1:-}"
  [ -n "$alvo" ] || { falar "uso: $0 travar usuario@host"; return 1; }
  [ -f "$CHAVE" ] || { falar "nao ha chave local ainda: rode $0 gerar"; return 1; }

  passo "prova final antes de desligar a senha"
  ssh -o BatchMode=yes -o ConnectTimeout=10 "$alvo" true 2>/dev/null || {
    falar "a chave nao entra em $alvo. Desligar a senha agora deixaria a maquina sem porta de entrada."
    falar "Rode: $0 instalar $alvo"
    return 1
  }
  falar "a chave entra. Agora a senha pode sair."

  passo "escrevendo a regra em $DROPIN_GERAL"
  conteudo_regra > "$DROPIN_GERAL" || {
    falar "sem permissao para escrever em $DROPIN_GERAL (precisa de sudo)"
    return 1
  }
  chmod 644 "$DROPIN_GERAL"

  passo "conferindo a configuracao antes de recarregar"
  ssh "$alvo" "sudo sshd -t" || {
    falar "sshd -t reprovou a configuracao. O daemon nao foi tocado."
    falar "veja do que ele esta reclamando: ssh $alvo 'sudo sshd -t -f $DROPIN_ALVO'"
    return 1
  }

  passo "recarregando o daemon sem derrubar a sessao"
  ssh "$alvo" 'sudo systemctl reload ssh || sudo systemctl reload sshd'

  passo "conferindo de novo, agora com a senha desligada"
  if ssh -o BatchMode=yes -o ConnectTimeout=10 "$alvo" true 2>/dev/null; then
    falar "ok: a chave continua entrando depois do reload"
  else
    falar "falhou. Reative a senha agora:"
    falar "  ssh $alvo 'sudo rm $DROPIN_GERAL && sudo systemctl reload ssh'"
    return 1
  fi
  falar "estado efetivo, lido pelo proprio daemon:"
  ssh "$alvo" "sudo sshd -T | grep -Ei '^(passwordauthentication|pubkeyauthentication|permitrootlogin)'"
}

case "${1:-}" in
  gerar)   gerar_chave ;;
  instalar) shift; dependencias || exit 1; instalar_chave "${1:-}" ;;
  travar)   shift; dependencias || exit 1; travar "${1:-}" ;;
  simular)  simular ;;
  *)
    falar "uso: $0 {gerar|instalar usuario@host|travar usuario@host|simular}"
    falar ""
    simular
    ;;
esac

A ordem que decide se você fica do lado de fora

O script escreve a regra em /etc/ssh/sshd_config.d/20-desliga-senha.conf e não mexe no sshd_config principal. Isso não é estilo, é requisito. No manual do sshd_config está escrito que para cada palavra-chave vale o primeiro valor encontrado. Medi as duas ordens com o mesmo drop-in: quando o Include vem antes, o no do drop-in vence o yes do arquivo principal e a senha sai. Quando o Include vem depois, o yes vence, a senha continua ligada e ninguém percebe, porque o arquivo parece certo.

O resultado disso é o utilitário que eu mais uso de ssh: sshd -T, que imprime a configuração efetiva como o daemon a leu, com as palavras-chave em minúsculas.

bash
sudo sshd -T | grep -Ei '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|permitrootlogin|maxauthtries)'

Da minha medição, com a regra completa escrita, o daemon devolveu:

bash
clientaliveinterval 300
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no
permitemptypasswords no
allowtcpforwarding yes
allowagentforwarding no
maxauthtries 3

Desligar só a senha não basta: KbdInteractiveAuthentication é o mesmo caminho com outro nome, e com ele ligado o cliente ainda recebe keyboard-interactive na lista da recusa. Meu teste na porta 2225 mostra exatamente isso. Passei a desligar os dois na mesma regra.

Como verificar

Rode o script em modo simulação primeiro, sem nenhum risco:

bash
chmod +x ssh-chave.sh && ./ssh-chave.sh simular

Depois, no seu servidor, o teste que importa é o do cliente sem chave. Se a senha realmente saiu, ele nem chega a perguntar nada:

bash
ssh -o PubkeyAuthentication=no -o BatchMode=yes usuario@host true

Permission denied (publickey) é o sinal de que funcionou. Se vier (publickey,password) ou (publickey,keyboard-interactive), a senha ainda está de pé e o Include do seu sshd_config provavelmente vem depois do que deveria.

Se der errado

O primeiro erro é desligar a senha sem chave instalada. A máquina responde, a senha some, e o acesso depende do provedor de console. Em VPS isso significa abrir o painel do provedor e digitar a senha root na mão. Antes de desligar, mantenha a sessão aberta e teste em uma segunda: se a segunda entrar, pode desligar na primeira.

O segundo é o Include no fim do arquivo. Algumas instalações antigas nem leem sshd_config.d, e aí a regra que você escreveu simplesmente não existe para o daemon. sshd -T | grep password responde vazio ou mostra yes: é o aviso mais barato que existe.

O terceiro é authorized_keys com permissão aberta. O StrictModes yes, que é o padrão, recusa a chave com um aviso seco no log do servidor. chmod 700 ~/.ssh e chmod 600 ~/.ssh/authorized_keys resolvem.

Custo dos tipos de chave, medido

tipo tempo para gerar tamanho da privada tamanho da pública
ed25519 0,005 s 411 bytes 69 caracteres
ecdsa 521 0,005 s 736 bytes 233 caracteres
rsa 4096 1,491 s 3381 bytes 717 caracteres

RSA de 4096 bits leva quase um segundo e meio por chave porque sorteia dois primos grandes de verdade. Ed25519 e ECDSA-521 levam cinco milissegundos. Escolha ed25519. Se precisar falar com um cliente antigo, de 2016 para trás, RSA 4096 continua sendo a menor das alternativas ruins.

Perguntas que aparecem sempre

Perdi a chave, tem como voltar? Com a senha desligada, pelo caminho normal não. A saída é o console do provedor, mais a senha do usuário. Por isso eu guardo a impressão digital de cada chave num arquivo de texto fora da máquina: ela não serve para entrar, serve para provar que a chave registrada no GitHub é a minha.

Uma chave por máquina ou uma só para tudo? Uma por máquina. Se a chave vaza, só uma máquina cai, e gerar leva cinco milissegundos.

Fontes: sshd_config(5), OpenBSD manual, OpenSSH 9.6p1; Ubuntu Server docs, leitura em 13 out 2026.

Minha opinião é que a chave não é mais “opção melhor”, é o piso. O que ainda faz sentido depois dela é o fail2ban na borda e a higiene de certificados, que também morrem rápido. O que se mede é o que se conserta.

Leia também

// Por e-mail

Os textos publicados aqui também vão por e-mail. O envio ainda não começou: quem deixar o endereço agora entra na primeira lista.

Guardo só o seu e-mail, a data e o registro desta autorização. Sem cookies, sem rastreador e nada compartilhado com terceiros. Detalhes na política de privacidade.