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:
- O usuário seleciona o certificado A1 (
.pfxou.p12); - informa a senha atual;
- escolhe uma nova senha;
- o PHP utiliza o OpenSSL para abrir o certificado;
- gera um novo arquivo
.pfxprotegido pela nova senha; - valida o arquivo gerado;
- envia o novo certificado para download;
- 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_openhabilitado;- 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

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.
Deixe um comentário
Participe da conversa. Seu e-mail não será publicado.