Certificado A1 com senha padrão? Crie uma nova senha pelo navegador

Dica Rápida

Dica Rápida

Quem trabalha com certificados digitais A1 provavelmente já passou por uma situação parecida: o certificado chega com uma senha padrão, definida pela certificadora, e essa mesma senha acaba sendo utilizada na instalação ou no envio do certificado para outros sistemas.

No meu ambiente de trabalho isso começou a me incomodar por dois motivos. Primeiro, pelo volume de certificados com que precisamos lidar. Segundo, porque alguns desses certificados precisam ser enviados para sistemas externos e ficar armazenados neles.

Nesse caso, achei mais interessante gerar uma nova cópia do certificado A1 protegida por uma senha diferente e mais forte, sem alterar o certificado original.

O problema é que nem todo usuário está familiarizado com o processo de importar e exportar certificados pelo Windows. Explicar esse procedimento toda vez também não parecia a solução mais prática.

Então resolvi fazer uma pequena página em PHP, disponível apenas na intranet, onde o próprio usuário pode enviar o certificado .pfx ou .p12, informar a senha atual e definir uma nova senha.

O processamento é feito no servidor utilizando o OpenSSL e, ao final, o navegador baixa uma nova cópia do certificado.

Como funciona

A ideia é bem simples:

  1. O usuário seleciona o certificado A1 (.pfx ou .p12);
  2. informa a senha atual;
  3. escolhe uma nova senha;
  4. o PHP utiliza o OpenSSL para abrir o certificado;
  5. gera um novo arquivo .pfx protegido pela nova senha;
  6. valida o arquivo gerado;
  7. envia o novo certificado para download;
  8. apaga os arquivos temporários utilizados durante o processo.

O certificado original não é alterado.

Também deixei uma opção de compatibilidade para gerar o PKCS#12 utilizando algoritmos aceitos por sistemas mais antigos. Isso pode ser útil principalmente quando o certificado precisa ser importado por algum ERP, aplicação Java ou outro software que tenha problemas com os padrões criptográficos mais recentes.

Requisitos

Para utilizar o exemplo é necessário ter:

  • PHP 7.4 ou superior;
  • proc_open habilitado;
  • OpenSSL instalado no servidor;
  • acesso ao binário openssl;
  • HTTPS, principalmente se a página não estiver restrita a uma rede interna confiável.

No meu caso, a página fica disponível na intranet, justamente por se tratar de certificados digitais e suas respectivas senhas.

Importante: um certificado A1 contém a chave privada do titular. Portanto, não coloque uma ferramenta desse tipo publicamente na internet sem avaliar cuidadosamente autenticação, HTTPS, permissões, logs, armazenamento temporário e a segurança do próprio servidor.

Código completo

<?php
/**
 * Troca de senha de certificado A1 (.pfx / .p12)
 * Requisitos: PHP 7.4+ com proc_open habilitado e binário `openssl` no servidor.
 * IMPORTANTE: use somente via HTTPS. Nada é gravado em definitivo: os arquivos
 * temporários são apagados ao final de cada requisição.
 */

declare(strict_types=1);
session_start();

const MAX_SIZE = 512 * 1024;            // 512 KB (A1 normalmente tem ~5-10 KB)
const OPENSSL  = 'openssl';             // ajuste o caminho se necessário, ex.: /usr/bin/openssl

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

/** Executa um comando sem shell; senhas vão por variável de ambiente. */
function run(array $cmd, array $env): array
{
    $proc = proc_open(
        $cmd,
        [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
        $pipes,
        null,
        array_merge(getenv() ?: [], $env)
    );
    if (!is_resource($proc)) {
        return [1, 'Não foi possível executar o openssl.'];
    }
    stream_get_contents($pipes[1]);
    $err = stream_get_contents($pipes[2]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    return [proc_close($proc), (string)$err];
}

function rrmdir(string $dir): void
{
    if (!is_dir($dir)) return;
    foreach (scandir($dir) ?: [] as $f) {
        if ($f === '.' || $f === '..') continue;
        $p = "$dir/$f";
        if (is_file($p)) { @unlink($p); }
    }
    @rmdir($dir);
}

$erro = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $tmp = '';
    try {
        if (!hash_equals($_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) {
            throw new RuntimeException('Sessão inválida. Recarregue a página e tente novamente.');
        }

        $old  = (string)($_POST['senha_antiga'] ?? '');
        $new  = (string)($_POST['senha_nova'] ?? '');
        $new2 = (string)($_POST['senha_nova2'] ?? '');

        if ($old === '' || $new === '') {
            throw new RuntimeException('Preencha a senha antiga e a nova senha.');
        }
        if ($new !== $new2) {
            throw new RuntimeException('A confirmação da nova senha não confere.');
        }
        if (strpos($old . $new, "\0") !== false) {
            throw new RuntimeException('Senha inválida.');
        }

        $f = $_FILES['certificado'] ?? null;
        if (!$f || $f['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($f['tmp_name'])) {
            throw new RuntimeException('Envie o arquivo do certificado (.pfx ou .p12).');
        }
        if ($f['size'] <= 0 || $f['size'] > MAX_SIZE) {
            throw new RuntimeException('Arquivo inválido ou grande demais.');
        }
        $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, ['pfx', 'p12'], true)) {
            throw new RuntimeException('Formato não suportado. Use .pfx ou .p12.');
        }

        // Diretório temporário privado (em memória, se disponível)
        $base = (is_dir('/dev/shm') && is_writable('/dev/shm')) ? '/dev/shm' : sys_get_temp_dir();
        $tmp  = $base . '/a1_' . bin2hex(random_bytes(8));
        if (!mkdir($tmp, 0700)) {
            throw new RuntimeException('Falha ao criar área temporária.');
        }

        $in  = "$tmp/in.pfx";
        $pem = "$tmp/all.pem";
        $out = "$tmp/out.pfx";
        move_uploaded_file($f['tmp_name'], $in);

        $env = ['OLD_PASS' => $old, 'NEW_PASS' => $new];

        // 1) Extrai chave + certificados (tenta moderno; depois -legacy para A1 antigos)
        [$code] = run([OPENSSL, 'pkcs12', '-in', $in, '-nodes', '-passin', 'env:OLD_PASS', '-out', $pem], $env);
        if ($code !== 0) {
            [$code] = run([OPENSSL, 'pkcs12', '-legacy', '-in', $in, '-nodes', '-passin', 'env:OLD_PASS', '-out', $pem], $env);
        }
        if ($code !== 0 || !is_file($pem) || filesize($pem) === 0) {
            throw new RuntimeException('Não foi possível abrir o certificado. Senha antiga incorreta ou arquivo inválido.');
        }

        // 2) Gera o novo PFX
        $legacyOut = !empty($_POST['compat_antiga']);
        $exp = [OPENSSL, 'pkcs12', '-export'];
        if ($legacyOut) {
            // Formato aceito por macOS, Windows, Java e ERPs (não depende do provider legacy)
            array_push($exp, '-certpbe', 'PBE-SHA1-3DES', '-keypbe', 'PBE-SHA1-3DES', '-macalg', 'sha1');
        }
        array_push($exp, '-in', $pem, '-out', $out, '-passout', 'env:NEW_PASS');
        [$code] = run($exp, $env);
        if ($code !== 0 || !is_file($out) || filesize($out) === 0) {
            throw new RuntimeException('Falha ao gerar o novo certificado.');
        }

        // 3) Valida o resultado com a nova senha
        $chk = [OPENSSL, 'pkcs12', '-in', $out, '-noout', '-passin', 'env:NEW_PASS'];
        [$code] = run($chk, $env);
        if ($code !== 0) {
            throw new RuntimeException('O novo certificado não passou na validação.');
        }

        // 4) Entrega o arquivo e limpa tudo
        $nome = preg_replace('/[^A-Za-z0-9._-]/', '_', pathinfo((string)$f['name'], PATHINFO_FILENAME));
        $nome = ($nome !== '' ? $nome : 'certificado') . '_nova_senha.pfx';

        header('Content-Type: application/x-pkcs12');
        header('Content-Disposition: attachment; filename="' . $nome . '"');
        header('Content-Length: ' . filesize($out));
        header('Cache-Control: no-store, no-cache, must-revalidate');
        header('Pragma: no-cache');
        readfile($out);
        rrmdir($tmp);
        exit;
    } catch (Throwable $e) {
        $erro = $e->getMessage();
    } finally {
        if ($tmp !== '') rrmdir($tmp);
    }
}
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trocar senha do certificado A1</title>
<style>
  :root { --bg:#f5f6f8; --card:#fff; --txt:#1c1f24; --muted:#6b7280; --bd:#d6d9de; --pri:#2563eb; --err:#b91c1c; --errbg:#fef2f2; }
  @media (prefers-color-scheme: dark) {
    :root { --bg:#14161a; --card:#1d2026; --txt:#e8eaed; --muted:#9aa1ab; --bd:#333843; --pri:#3b82f6; --err:#fca5a5; --errbg:#3a1d1d; }
  }
  * { box-sizing: border-box; }
  body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; padding:16px;
         background:var(--bg); color:var(--txt); font:16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
  .card { width:100%; max-width:440px; background:var(--card); border:1px solid var(--bd); border-radius:12px; padding:24px; }
  h1 { font-size:1.25rem; margin:0 0 4px; }
  p.sub { margin:0 0 20px; color:var(--muted); font-size:.9rem; }
  label { display:block; font-size:.9rem; margin:14px 0 4px; }
  input[type=file], input[type=password] { width:100%; padding:10px; border:1px solid var(--bd); border-radius:8px; background:transparent; color:inherit; font:inherit; }
  .chk { display:flex; gap:8px; align-items:flex-start; margin-top:16px; font-size:.85rem; color:var(--muted); }
  button { width:100%; margin-top:20px; padding:12px; border:0; border-radius:8px; background:var(--pri); color:#fff; font:inherit; font-weight:600; cursor:pointer; }
  .err { background:var(--errbg); color:var(--err); padding:10px 12px; border-radius:8px; margin-bottom:12px; font-size:.9rem; }
  small { display:block; margin-top:16px; color:var(--muted); font-size:.8rem; }
</style>
</head>
<body>
<main class="card">
  <h1>Trocar senha do certificado A1</h1>
  <p class="sub">Envie o .pfx/.p12, informe as senhas e o novo arquivo será baixado automaticamente.</p>

  <?php if ($erro): ?><div class="err" role="alert"><?= h($erro) ?></div><?php endif; ?>

  <form method="post" enctype="multipart/form-data" autocomplete="off">
    <input type="hidden" name="csrf" value="<?= h($_SESSION['csrf']) ?>">

    <label for="certificado">Certificado (.pfx / .p12)</label>
    <input type="file" id="certificado" name="certificado" accept=".pfx,.p12" required>

    <label for="senha_antiga">Senha antiga</label>
    <input type="password" id="senha_antiga" name="senha_antiga" required autocomplete="off">

    <label for="senha_nova">Nova senha</label>
    <input type="password" id="senha_nova" name="senha_nova" required autocomplete="new-password">

    <label for="senha_nova2">Confirmar nova senha</label>
    <input type="password" id="senha_nova2" name="senha_nova2" required autocomplete="new-password">

    <label class="chk">
      <input type="checkbox" name="compat_antiga" value="1" checked>
      <span>Formato compatível (macOS, Windows, Java e sistemas antigos). Recomendado; desmarque só se precisar de AES-256.</span>
    </label>

    <button type="submit">Gerar novo certificado</button>
  </form>
  <small>O arquivo e as senhas não são armazenados: tudo é processado em memória temporária e apagado ao final.</small>
</main>
</body>
</html>

Tela do app

Tela do app

Algumas observações

O script não troca a senha do arquivo original. Ele cria um novo .pfx, mantendo o certificado e a chave privada, mas protegendo a nova cópia com a senha escolhida.

Durante o processamento, o certificado é extraído temporariamente para que o OpenSSL possa gerar o novo PKCS#12. O script tenta utilizar /dev/shm, quando disponível, e remove os arquivos temporários ao final da requisição.

As senhas também não são colocadas diretamente na linha de comando do OpenSSL. Elas são fornecidas por variáveis de ambiente (OLD_PASS e NEW_PASS).

Outro detalhe é a opção de compatibilidade. Quando marcada, o novo arquivo utiliza 3DES/SHA-1 no PKCS#12, o que aumenta a compatibilidade com softwares mais antigos. Se todos os sistemas utilizados aceitarem os padrões mais novos, essa opção pode ser desmarcada para gerar o arquivo utilizando a configuração moderna do OpenSSL.

Por que fiz dessa forma?

É uma solução pequena para um problema igualmente pequeno, mas recorrente.

Em vez de orientar cada usuário a importar o certificado no Windows, abrir o gerenciador de certificados, localizar o certificado correto, exportá-lo novamente, escolher as opções certas e definir uma nova senha, deixei o procedimento resumido a um formulário.

Seleciona o certificado, digita a senha atual, escolhe uma senha nova e pronto.

Para uma ferramenta interna, acabou sendo bem mais simples.


Este conteúdo foi útil?

Sua opinião ajuda a melhorar os próximos conteúdos.

Enviar sugestão

Deixe um comentário

Participe da conversa. Seu e-mail não será publicado.

★★★★★