ShieldSign PKIDocumentação

Assinar PDFs no seu sistema

O fluxo completo: o seu navegador, o seu servidor e a API, para um documento ou um lote.

Este guia mostra o fluxo completo para assinar um ou vários PDFs no seu sistema. Se você ainda não fez uma assinatura de ponta a ponta, comece pela Primeira assinatura.

A arquitetura#

O navegador do usuário não fala com a API. Ele chama três endpoints do seu servidor, e o seu servidor chama a API com a chave de API:

 Navegador (JS + extensão)         Seu servidor                    API ShieldSign PKI
 ─────────────────────────         ────────────                    ──────────────────
 1  envia o(s) PDF(s) ───────────▶ POST /assinar/iniciar ────────▶ POST /v1/pades-signatures/multipart
                       ◀────────── { assinaturaId, documentos }  ◀─ { token, documents }
 2  lê o certificado (DER)
    ──────────────────────────────▶ POST /assinar/preparar ──────▶ POST .../{token}/prepare
                       ◀────────── { digests }                   ◀─ { digests, certificate }
 3  assina com a extensão: uma aprovação, um PIN, a chave nunca sai do token
 4  envia as assinaturas ────────▶ POST /assinar/concluir ───────▶ POST .../{token}/complete
                                    baixa os PDFs ◀──────────────── GET .../documents/{i}  (ou .zip)
                       ◀────────── { documentos }                   DELETE .../{token}

Os nomes /assinar/... são do seu sistema: use os que fizerem sentido. O que importa é o desenho:

  • A chave de API e o token da sessão ficam só no servidor. O navegador conhece apenas um identificador seu e opaco (assinaturaId).
  • O multipart é o passo de início (POST /v1/pades-signatures/multipart). O prepare e o complete são os mesmos para um ou para N documentos.
  • Num lote, um único PIN e uma única aprovação assinam todos os documentos.

O servidor (PHP)#

O exemplo usa o cliente PHP. Com outra linguagem, os mesmos três passos são chamadas HTTP comuns: veja a Referência da API.

use ShieldSign\Client;
use ShieldSign\ApiException;

function cliente(): Client {
    return new Client(getenv('SHIELDSIGN_URL'), getenv('SHIELDSIGN_KEY'), timeoutSeconds: 300);
}

1. Iniciar: /assinar/iniciar#

// $arquivos: lista de ['path' => caminho do PDF no disco, 'name' => nome do arquivo]
$r = cliente()->startMultipart($arquivos, 'pades-icpbrasil-ad-rb', 'icp-brasil', callbackArgument: "usuario:$uid");

$id = bin2hex(random_bytes(16));            // identificador opaco que o navegador vai conhecer
guardarAssinatura($id, [
    'token' => $r['token'],                  // o token da API fica SÓ AQUI
    'documentos' => $r['documents'],         // transactionId e sha256 de cada PDF recebido
    'documentoEsperado' => $cpfOuCnpjDoSignatario,  // do seu cadastro: veja "Recusar antes do PIN"
]);
// devolva ao navegador: { assinaturaId: $id, documentos: [...] }

O arquivo vai do disco para a API em blocos, sem base64 e sem carregar o PDF na memória do PHP. Para um único PDF pequeno (até cerca de 24 MiB), startJson($bytes, $politica, $contexto) é mais simples.

Guarde o token com segurança

Em fluxos por link com código de confirmação, guarde o token no seu banco, cifrado (por exemplo, com sodium_crypto_secretbox), amarrado ao identificador da assinatura. Não use a sessão PHP se o signatário não estiver logado.

2. Preparar: /assinar/preparar#

$a = lerAssinatura($in['assinaturaId']);
$p = cliente()->prepare($a['token'], $in['certificado']);   // certificado DER em base64, enviado pelo navegador

// A API já validou cadeia, validade e uso da chave. Agora confira QUEM é:
$c  = $p['certificate'];
$pk = $c['pkiBrazil'];                                       // cpf, cnpj, responsibleCpf, companyName...
if ($a['documentoEsperado'] && !in_array($a['documentoEsperado'], [$pk['cpf'], $pk['cnpj'], $pk['responsibleCpf']], true)) {
    cliente()->discard($a['token']);                         // recusa ANTES do PIN: nada foi assinado nem cobrado
    responder(422, ['erro' => 'certificado_de_outra_pessoa']);
}

$a['idempotencyKey'] = bin2hex(random_bytes(16));            // veja "Sessões, prazos e repetição segura"
guardarAssinatura($in['assinaturaId'], $a);

// devolva SÓ o necessário ao navegador: os dados a assinar, um por documento, na mesma ordem do envio
responder(200, ['digests' => $p['digests'], 'warnings' => $p['warnings'] ?? []]);

Cada item de digests traz o index, o toSignData (base64) e o digestAlgorithm. Com um documento, esses campos também vêm no topo da resposta.

3. Concluir: /assinar/concluir#

$c = cliente()->complete($a['token'], $in['assinaturas'], 'link', $a['idempotencyKey']);

foreach ($c['documents'] as $d) {
    $destino = "$dir/assinado-{$d['index']}.pdf";
    cliente()->downloadDocument($a['token'], $d['index'], $destino);
    // confira o que recebeu: o hash do arquivo baixado deve ser o que a API informou
    if (!hash_equals($d['signedSha256'], hash_file('sha256', $destino))) {
        throw new RuntimeException('arquivo assinado corrompido no download');
    }
    // guarde: $d['transactionId'], $d['signedSha256'], $c['certificate']['pkiBrazil'] e o requestId
}
cliente()->discard($a['token']);   // libera a sessão e apaga os arquivos do servidor da API

Alternativas para entregar o resultado:

Saída Como pedir Quando usar
link complete(..., 'link') Padrão em lote. Baixe cada PDF com downloadDocument ou todos num .zip com downloadZip.
inline complete(...) (padrão com um documento) Um PDF: o signedPdf vem em base64 na própria resposta e a sessão é apagada na hora.

O navegador (JavaScript)#

O código abaixo roda no navegador, usa o SDK JavaScript para falar com a extensão e chama só os seus endpoints. Nenhuma chave de API existe aqui.

import { ExtensionSigner, A3Signer, ExtensionError } from "/assets/shieldsign-sdk.browser.mjs";

const b64 = (bytes) => btoa(String.fromCharCode(...bytes));          // para arquivos grandes, converta em pedaços
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));

async function post(caminho, corpo) {
  const res = await fetch(caminho, {
    method: "POST", credentials: "include",
    ...(corpo instanceof FormData ? { body: corpo } : { headers: { "content-type": "application/json" }, body: JSON.stringify(corpo) }),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(json.mensagem ?? `HTTP ${res.status}`), { codigo: json.erro, requestId: json.requestId });
  return json;
}

export async function assinar(arquivos, signer) {
  // 1) iniciar: envia os PDFs
  const form = new FormData();
  arquivos.forEach((f) => form.append("pdf[]", f, f.name));
  const ini = await post("/assinar/iniciar", form);

  // 2) preparar: envia o certificado; a API o valida ANTES de o usuário digitar o PIN
  const prep = await post("/assinar/preparar", { assinaturaId: ini.assinaturaId, certificado: b64(await signer.certificate()) });

  // 3) assinar: UMA aprovação e UM PIN para todos os documentos
  const algoritmo = prep.digests[0].digestAlgorithm;
  const dados = prep.digests.map((d) => unb64(d.toSignData));
  const assinaturas = await signer.signMany(dados, algoritmo);

  // 4) concluir
  return post("/assinar/concluir", { assinaturaId: ini.assinaturaId, assinaturas: assinaturas.map(b64) });
}

// uso
if (await ExtensionSigner.isAvailable()) {
  const [cert] = await ExtensionSigner.listCertificates();      // normalmente você mostra a lista e o usuário escolhe
  const resultado = await assinar([...inputArquivos.files], new A3Signer(cert.thumbprint));
}

Trate os erros da extensão (ExtensionError) separadamente dos do seu servidor: o primeiro vem do computador do usuário (cancelou, PIN errado, extensão ausente), o segundo traz o código estável da API em erro. Veja Extensão e agente e Erros.

O que o seu sistema deve fazer#

  1. Autenticar o usuário antes de cada endpoint. Sem login, ninguém assina nem gasta cota. Proteja os três POST contra CSRF.
  2. Manter o token no servidor e entregar ao navegador só o identificador opaco.
  3. Comparar o CPF/CNPJ do certificado com o esperado no prepare, antes do PIN.
  4. Conferir o SHA-256 do arquivo baixado contra signedSha256 antes de gravar.
  5. Registrar o X-Request-Id de cada erro (ApiException::$requestId).
  6. Gerar o Idempotency-Key no prepare e reutilizá-lo no complete; guardar o resultado da conclusão para devolvê-lo se o navegador repetir.
  7. Apagar a sessão (discard) ao terminar.

O que a API devolve para guardar#

Guarde junto de cada documento:

Campo O que é
transactionId Identificador público da transação, que aparece no painel e na auditoria.
sha256 SHA-256 do PDF que a API recebeu: compare com o seu.
signedSha256 SHA-256 do PDF assinado.
certificate.pkiBrazil CPF/CNPJ e demais dados do certificado que de fato assinou, lidos pelo servidor. Use este bloco para auditoria.
X-Request-Id (cabeçalho) Identifica a chamada para o suporte.

O PDF assinado preserva os bytes do original (atualização incremental): o arquivo assinado começa com o seu PDF original.

Lote é tudo ou nada

No complete de um lote, se uma assinatura for recusada (invalid_signature), nada é gerado: a mensagem diz documento N: ... e a sessão continua válida para uma nova tentativa. A cobrança conta por documento concluído, e o lote precisa caber na cota restante do mês (403 quota_exceeded no início, se não couber).

Ajustes do PHP e do servidor web#

Item Por quê
upload_max_filesize e post_max_size Devem comportar o maior lote, senão o PHP descarta o upload antes de chamar a API.
max_execution_time e o timeout do proxy (proxy_read_timeout, fastcgi_read_timeout) Pelo menos 120 s: o complete de PDF grande, com carimbo de tempo, pode levar segundos.
memory_limit Não precisa crescer: os arquivos vão por streaming.
client_max_body_size (nginx) Igual ao upload.
Disco temporário do PHP Recebe o upload antes de ir à API; apague depois do startMultipart.

Outras linguagens#

A API é HTTP puro. Em Node, use o ShieldSignClient do SDK JavaScript; nas demais linguagens, siga a Referência da API e o fluxo deste guia.

ShieldSign PKI · Shield Certificadora · contato@shieldcertificadora.com.br · Privacidade