---
id: SVC-DIGITALSIGNATURE
tipo: servico
onda: 2
status: concluido
unidades: [SVC-DIGITALSIGNATURE]
fontes:
  - V2/Services/DigitalSignature/
depende_de: [CORE-BOOT, CORE-DATA]
consumidores: [MOD-INFRAESTRUTURA-CERTIFICADODIGITAL]
criticidade: alta
dono: SvcSignature
---

# Serviço DigitalSignature

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Recebe um PKCS#12 (`.pfx`/`.p12`) + senha, extrai os metadados X.509 e a chave privada, guarda a chave cifrada por usuário; depois re-renderiza um PDF via FPDI/TCPDF, aplica uma assinatura PKCS#7 detached (com carimbo visual opcional) e grava uma linha de auditoria por operação. |
| Entrada | `DigitalSignatureService::importCertificate()`, `signPdf(SignatureRequest)`, `getUserCertificates()`, `getCertificate()`, `deleteCertificate()`, `getSignatureHistory()`, `getGlobalSignatureHistory()`; `PdfSigner::sign()`; `CertificateManager::*`. Não há rotas HTTP próprias. |
| Saída | `CertificateDTO` (metadados públicos), `SignatureResult` (binário do PDF assinado + hash SHA-256 maiúsculo), coleções de `stdClass` vindas de `DB::table()`, linhas em `v2_digital_certificates` e `v2_signature_logs`. |
| Efeitos colaterais | INSERT em `v2_digital_certificates`; INSERT em `v2_signature_logs` em sucesso **e** em falha de assinatura; `UPDATE last_used_at`; `Log::info` no delete; `Log::error` na falha de importação; arquivos temporários em `sys_get_temp_dir()`; chave privada em claro apenas em memória durante a chamada. Não há cache, busca, e-mail, fila ou evento. |
| Particularidade central | A chave privada é cifrada com AES-256-CBC usando uma chave derivada de `sha256(config('app.key') . '\|v2-digital-signature\|' . $userId)` — não há KDF com custo, salt por registro, MAC/AEAD nem versionamento de chave; e o e-mail de suporte (`config('v2.support.email')`) ignora o escopo por usuário, mas **não consegue decifrar** a chave de outro usuário. |
| Como se integra | Serviço Laravel resolvido por auto-wiring (não há binding em provider); migrações descobertas por `V2ServiceProvider` em `V2/Services/*/Migrations`; consumidor único é o módulo `Infraestrutura/CertificadoDigital`. |
| Se removido, quebra | `CertificadoDigitalCmsController` (index, store, signForm, signDocument, show, destroy), o model `DigitalCertificate` usado pelo `ActivityLoggerService` e as duas tabelas de certificado/auditoria; não há outro chamador no código. |
| Superfície pública | 4 classes de serviço (`DigitalSignatureService`, `CertificateManager`, `PdfSigner`, `PrivateKeyVault`), 2 parsers, 2 repositórios, 3 DTOs, 3 exceções, 1 interface de signer, 1 interface de tema, 3 temas, 1 builder visual, 2 migrations. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

**Importação.** `importCertificate($userId, $userEmail, $pfxData, $pfxPassword, $name = '')` recebe os bytes de um PKCS#12 e a senha. `Pkcs12Parser::parse()` chama `openssl_pkcs12_read()` e devolve `['cert' => PEM, 'pkey' => PEM, 'extracerts' => [...]]` (`V2/Services/DigitalSignature/Parsers/Pkcs12Parser.php:30-50`); `X509Parser::parse()` transforma o certificado folha em `CertificateDTO` com CN, O, CPF/CNPJ, serial, issuer/subject textuais, validade, thumbprint SHA-256 e algoritmo declarado (`V2/Services/DigitalSignature/Parsers/X509Parser.php:66-79`). O serviço valida a validade, extrai a chave privada, cifra-a, e insere uma linha em `v2_digital_certificates` com `id = v2_uuid()`, `is_active = true` e timestamps locais (`V2/Services/DigitalSignature/DigitalSignatureService.php:50-87`). Retorna o DTO — **não retorna o `id`**; o consumidor reencontra o registro por `thumbprint_sha256` + `user_id` (`V2/Modules/Infraestrutura/CertificadoDigital/Controllers/CertificadoDigitalCmsController.php:137-141`).

**Assinatura.** `signPdf(SignatureRequest)` valida os campos obrigatórios, carrega o registro do certificado com escopo de usuário, verifica a validade, decifra a chave privada, monta metadados, chama `PdfSigner::sign()`, calcula `strtoupper(hash('sha256', $signedPdfContent))`, registra `success` em `v2_signature_logs`, atualiza `last_used_at` e devolve `SignatureResult(success=true, pdf, hash, signerName, signerDocument, signedAt, null, certificateId)` (`V2/Services/DigitalSignature/DigitalSignatureService.php:119-188`). Em `SignatureException` grava `status=failed` com `error_message` e relança (`:190-204`).

**O PDF devolvido não é o PDF de entrada com uma assinatura anexada**: todas as páginas são importadas por `FPDI::importPage()`/`useTemplate()` e redesenhadas em um novo documento TCPDF (`V2/Services/DigitalSignature/PdfSigner.php:133-149`), sobre o qual o carimbo é desenhado e a assinatura é aplicada. A versão de saída é a do TCPDF (1.7), elevada para a do original se este declarar versão maior (`vendor/setasign/fpdi/src/FpdiTrait.php:113-119`).

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| INSERT do certificado (PEM público + chave cifrada + IV + metadados) | `v2_digital_certificates` | `certificateRepository->insert($record)` | `V2/Services/DigitalSignature/DigitalSignatureService.php:62-85` |
| Soft delete: `deleted_at`, `is_active=false`, `updated_at` | `v2_digital_certificates` | `softDelete()` | `V2/Services/DigitalSignature/Repositories/CertificateRepository.php:58-68` |
| `last_used_at`/`updated_at` após assinar | `v2_digital_certificates` | `touchLastUsedAt()` | `V2/Services/DigitalSignature/DigitalSignatureService.php:177`; `Repositories/CertificateRepository.php:70-77` |
| INSERT de auditoria de sucesso (hash do PDF assinado, nome/tamanho do original, signatário) | `v2_signature_logs` | `signatureLogRepository->log([... 'status' => 'success'])` | `V2/Services/DigitalSignature/DigitalSignatureService.php:164-175` |
| INSERT de auditoria de falha (`document_hash=''`, `status='failed'`) | `v2_signature_logs` | catch de `SignatureException` | `V2/Services/DigitalSignature/DigitalSignatureService.php:190-201` |
| Log de aplicação na remoção | canal padrão `Log` | `Log::info('[DigitalSignatureService] Certificado removido: ...')` | `V2/Services/DigitalSignature/DigitalSignatureService.php:111-116` |
| Log de aplicação na falha de importação | canal padrão `Log` | `Log::error('[DigitalSignatureService] Erro ao importar certificado: ...')` | `V2/Services/DigitalSignature/DigitalSignatureService.php:90-97` |
| Dois arquivos temporários (`v2_sign_input_*`, `v2_sign_output_*`) e remoção no `finally` | `/tmp` (ou `sys_get_temp_dir()`) | `tempnam`, `chmod 0600`, `safeUnlink` | `V2/Services/DigitalSignature/PdfSigner.php:86-103,246-253` |
| Nenhum cache, índice de busca, e-mail, evento ou fila | — | ausência de chamadas | `[C]` busca no diretório: só `DB`, `Log`, `openssl`, TCPDF/FPDI |

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| PFX válido, senha correta, dentro da validade | `CertificateDTO` preenchido; 1 linha em `v2_digital_certificates`; chave privada cifrada; nenhuma senha persistida. |
| PFX vazio | `CertificateException` `CERT_LOAD_FAILED`, mensagem `Dados do certificado PFX/P12 vazios.`, HTTP code 400 (`Pkcs12Parser.php:23-25`). |
| Senha vazia | `CertificateException` `CERT_INVALID_PASSWORD`, `Senha não fornecida.` (`Pkcs12Parser.php:26-28`). |
| Senha literal `"0"` | Também rejeitada, porque o teste é `empty($password)` — um PKCS#12 com senha `"0"` não é aceito (`Pkcs12Parser.php:26-28`). |
| Senha errada ou arquivo inválido | `CertificateException` `CERT_INVALID_PASSWORD`, `Senha incorreta ou arquivo PFX/P12 inválido.` (quando o erro do OpenSSL cita `PKCS12`/`password`) ou `Falha ao ler PKCS#12. Verifique o arquivo e a senha.` (`Pkcs12Parser.php:35-43`). |
| PFX só com certificado ou só com chave | `CertificateException` `CERT_LOAD_FAILED`, `O arquivo PFX não contém um certificado público e uma chave privada correspondente.` (`Pkcs12Parser.php:46-48`). |
| Certificado fora da validade e `userEmail !== config('v2.support.email')` | `CertificateException` `CERT_EXPIRED`, mensagem de importação (`...Apenas o suporte técnico pode cadastrar certificados fora do prazo.`) (`DigitalSignatureService.php:43-48`). |
| Mesmo caso com e-mail de suporte | Importação permitida; a checagem de validade é ignorada (`DigitalSignatureService.php:43`). |
| `signPdf` com PDF vazio | `SignatureException` `SIG_INVALID_INPUT`, `Conteúdo do PDF não fornecido.` (`DTOs/SignatureRequest.php:155-160`). |
| `signPdf` sem `certificateId`/`userId` | `SignatureException` `SIG_INVALID_INPUT`, `ID do certificado não informado.` / `Usuário não informado.` (`SignatureRequest.php:162-175`). |
| Certificado inexistente, de outro usuário ou soft-deleted (usuário comum) | `CertificateException` `CERT_NOT_FOUND`, `Certificado não encontrado ou acesso negado.`, code 404 (`CertificateRepository.php:45-53`). |
| Certificado expirado no ato de assinar (usuário comum) | `CertificateException` `CERT_EXPIRED`, `O certificado selecionado está expirado. Apenas o suporte técnico pode utilizar certificados expirados.` (`DigitalSignatureService.php:124-131`). |
| Chave cifrada corrompida/IV inválido | `CertificateException` `CERT_DECRYPTION_FAILED`: `Dados de chave privada criptografada inválidos.`, `Base64 inválido nos dados criptografados.` ou `Falha ao descriptografar chave privada.` (`PrivateKeyVault.php:87-121`). |
| PDF ilegível pelo FPDI | `SignatureException` `SIG_SIGNER_FAILED`, code 500, prefixo `Erro ao assinar PDF: ...`; se a mensagem citar `FPDI`/`compression technique`, é substituída pelo texto longo que pede PDF 1.4 (`PdfSigner.php:235-244`). |
| PDF sem páginas | `SignatureException` `SIG_PDF_LOAD_FAILED`, `O PDF não contém páginas para assinar.` (`PdfSigner.php:118-123`). |
| Certificado ou chave vazios no signer | `SignatureException` `SIG_CERTIFICATE_MISMATCH`, `Certificado ou chave privada não fornecidos.` (`PdfSigner.php:74-80`). |
| Impressão do resultado de falha do signer | `SignatureException` `SIG_PDF_RENDER_FAILED`, `Falha ao gerar o PDF assinado.` (`PdfSigner.php:222-228`). |
| `getGlobalSignatureHistory` com e-mail ≠ suporte | `CertificateException` `CERT_ACCESS_DENIED`, `Acesso global ao histórico requer privilégios de suporte.`, code 403 (`SignatureLogRepository.php:51-57`). |

O código HTTP só existe em `getCode()` da exceção; o consumidor não o usa diretamente — converte a maioria em redirect com flash `error`, mas `show` e `destroy` consultam `getErrorCode()` para converter `CERT_NOT_FOUND` em `abort(404)` (`CertificadoDigitalCmsController.php:302-317,330-334,395-398`). `SignatureResult::$success` é sempre `true` quando `signPdf` retorna; não existe caminho de retorno com `success=false` (o serviço lança exceção).

### 1.4 O que o serviço deliberadamente NÃO faz

- **Não valida cadeia de confiança, raiz ICP-Brasil, CRL ou OCSP.** Nenhuma chamada a `openssl_x509_checkpurpose`, `openssl_x509_verify` ou download de CRL no diretório (`[C]` ausência; `[I]` a única leitura é `openssl_x509_parse`, `X509Parser.php:24-29`).
- **Não faz PAdES, TSA ou LTV.** Ver o documento `assinatura-pdf.md` e §3.5.
- **Não verifica o PDF produzido**; não há `verifySignature()` apesar do README listar como próximo passo (`V2/Services/DigitalSignature/README.md:99-105`).
- **Não autoriza o chamador.** Não há middleware, policy, `v2_can()` nem checagem de papel dentro do serviço; o único controle embutido é o bypass por e-mail de suporte e o filtro `user_id` nas queries (`CertificateRepository.php:28-43`). A permissão `CertificadoDigital:write` é aplicada pelo controller do módulo apenas nas actions de escrita.
- **Não grava o PFX original nem a senha.** O PFX só existe em memória; a senha é zerada apenas na cópia local do parser (`Pkcs12Parser.php:33`), mas a variável do serviço/controller permanece até o fim da requisição.
- **Não detecta duplicidade.** Não há `unique` em `thumbprint_sha256`/`serial_number` e o serviço não consulta antes do INSERT (`Migrations/2026_07_01_000001_create_v2_digital_certificates_table.php:79-82`).
- **Não é atômico.** Não há transação envolvendo assinatura + auditoria + `last_used_at`; uma falha depois da assinatura (por exemplo, INSERT de log) propaga exceção sem devolver o PDF já assinado em memória.
- **Não apaga a chave cifrada no delete.** O soft delete preserva `private_key_encrypted` e `private_key_iv`.
- **Não envia notificação, e-mail, webhook nem invalida cache.**
- **Não suporta A3.** `SignerInterface` existe, mas não é implementada por `PdfSigner` e não há outra implementação (`V2/Services/DigitalSignature/Signers/SignerInterface.php:15-36`; `PdfSigner.php:32`).
- **Não aceita outros formatos de certificado.** Só PKCS#12 (`.pfx`/`.p12`) com senha; não há importação de PEM avulso, JKS, token/cartão A3 ou certificado gerenciado por provedor. O consumidor ainda restringe a extensão a `pfx`/`p12` e o arquivo a 5 MB (`CertificadoDigitalCmsController.php:101-117`).

### 1.5 Efeito por perfil

| Perfil | Efeito |
|---|---|
| Público do portal | Nenhum: o serviço não tem rota pública. |
| API V2 | Nenhum: a unidade não tem `Routes/api.php`; não há token, throttle ou envelope de API. |
| Operador CMS com `CertificadoDigital:write` | Pode importar PFX, assinar PDF e remover certificado pelo módulo; vê apenas os próprios certificados (`CertificadoDigitalCmsController.php:83,98,194,214,369`). |
| Qualquer usuário autenticado do CMS | Vê a lista e o detalhe dos próprios certificados e o histórico, pois `index`/`show` não chamam `v2_can`; as actions de escrita (`create`, `store`, `signForm`, `signDocument`, `destroy`) respondem 403 sem `CertificadoDigital:write`. |
| `config('v2.support.email')` (`suporte@maxima.inf.br` por default) | Lista e detalha certificados de **todos** os usuários; pode importar e usar certificado expirado; acessa o histórico global. Não consegue assinar com certificado de terceiro porque a decifragem usa o `userId` do solicitante (§3.3). |

## 2. Abordagem

### 2.1 Estratégia e por quê

O serviço é um **orquestrador fino** com responsabilidades separadas: `CertificateManager` delega parsing e criptografia; `Pkcs12Parser` fala com `openssl_pkcs12_read`; `X509Parser` fala com `openssl_x509_parse`; `PrivateKeyVault` cifra; `CertificateRepository`/`SignatureLogRepository` isolam `DB::table`; `PdfSigner` isola TCPDF/FPDI; temas implementam `StampThemeInterface` e são injetados em `VisualStampBuilder`. A motivação observável é trocar a aparência do selo sem tocar na assinatura e manter o orquestrador independente do mecanismo criptográfico.

A abstração de signer, no entanto, ficou **incompleta**: `PdfSigner` não declara `implements SignerInterface`, e o serviço type-hinta a classe concreta (`DigitalSignatureService.php:26-31`). O único ponto de extensão real hoje é o tema do carimbo (`PdfSigner::resolveStampTheme`, `:271-296`).

Fluxo de dados:

```mermaid
flowchart TD
    A[PFX bytes + senha] --> B[Pkcs12Parser::parse]
    B --> C[cert PEM + pkey PEM + extracerts]
    C --> D[X509Parser::parse]
    D --> E[CertificateDTO]
    E --> F{isValidNow?}
    F -- não e não-suporte --> X[CertificateException CERT_EXPIRED]
    F -- sim ou suporte --> G[PrivateKeyVault::encrypt]
    G --> H[(v2_digital_certificates)]
    H --> I[signPdf]
    I --> J[getCertificate: filtro user_id]
    J --> K[isValidNow PEM]
    K --> L[PrivateKeyVault::decrypt]
    L --> M[PdfSigner::sign]
    M --> N[FPDI importa páginas]
    N --> O[desenha carimbo]
    O --> P[TCPDF setSignature PKCS7 detached]
    P --> Q[PDF assinado]
    Q --> R[hash SHA-256 do output]
    R --> S[(v2_signature_logs success)]
    S --> T[touchLastUsedAt]
    T --> U[SignatureResult]
    P -. SignatureException .-> V[(v2_signature_logs failed)]
    V --> W[relança]
```

### 2.2 Mecânica passo a passo

**Importação** (`DigitalSignatureService::importCertificate`, `:38-99`):

1. `loadFromPfx()` → `Pkcs12Parser::parse($pfxData, $password)`; rejeita dados/senha vazios; executa `openssl_pkcs12_read`; exige `cert` e `pkey` (`Pkcs12Parser.php:21-50`).
2. `X509Parser::parse($certs['cert'])` → `openssl_x509_read`, `openssl_x509_parse($resource, false)`, thumbprint SHA-256, `extractDocumentNumber`, `serialToHex`, datas em `Y-m-d H:i:s`, `formatDN` (`X509Parser.php:24-79`).
3. `CertificateDTO::isValidNow()` compara as strings `validFrom <= agora <= validUntil` (`DTOs/CertificateDTO.php:121-126`). Fora do prazo, só passa se `$userEmail === config('v2.support.email')`.
4. `extractPrivateKeyFromPfx()` chama o parser **de novo** (`CertificateManager.php:36-39`), repetindo `openssl_pkcs12_read`.
5. `encryptPrivateKey()` → `PrivateKeyVault::encrypt()` (AES-256-CBC, IV aleatório de 16 bytes, saída base64 de `encrypted` e `iv`).
6. Nome amigável vazio → `commonName` ou `Certificado d/m/Y` (`:55-57`).
7. Monta o array com `id = v2_uuid()` e timestamps `date('Y-m-d H:i:s')`; INSERT via query builder.
8. Qualquer erro não-`CertificateException` vira `CertificateException` `CERT_ENCRYPTION_FAILED` com a mensagem `Erro ao salvar certificado no banco de dados.` e code 500, com a original em `previous` (`:88-98`).

**Assinatura** (`DigitalSignatureService::signPdf`, `:119-205`):

1. `SignatureRequest::validate()`: exige `pdfContent`, `certificateId`, `userId`; se `pdfFilename` vazio, usa `documento_signed.pdf` (`SignatureRequest.php:153-178`).
2. `getCertificate($id, $userId, $userEmail)` → query `where id`, `whereNull('deleted_at')`, `where user_id` quando não-suporte; ausência → `CERT_NOT_FOUND` 404 (`CertificateRepository.php:35-56`).
3. `isCertificateValidNow($certRecord->certificate_pem)` relê o PEM e compara epochs; datas ausentes (0) são **ignoradas** e o certificado é considerado válido (`X509Parser.php:99-106`). Fora do prazo, só suporte prossegue.
4. `decryptPrivateKey($encrypted, $iv, $request->userId)` — chave derivada do usuário **requisitante**.
5. `signerName = common_name`, senão `name`, senão `'Desconhecido'` (`:139-142`).
6. Metadados: `name`, `location`, `reason`, `contact_info`, `pos_x`, `pos_y`, `pos_page`, `theme_type` (`:145-154`).
7. `PdfSigner::sign($pdfContent, $certificate_pem, $privateKeyPem, $metadata, $addVisualStamp)`.
8. Hash do **output assinado**, maiúsculo; INSERT de log `success`; `touchLastUsedAt`; `SignatureResult`.
9. Em `SignatureException`: INSERT de log `failed` com `document_hash = ''` e relança. Exceções de banco/criptografia fora desse `catch` não geram log de falha. Não há transação: o INSERT de log e o `UPDATE last_used_at` são best-effort e podem falhar depois de o PDF já estar assinado em memória; se o próprio INSERT de log de falha falhar, ele mascara a `SignatureException` original.

### 2.3 Trechos de código pivô

```php
// V2/Services/DigitalSignature/CertificateManager.php:30-39 — a fachada de certificado:
// a mesma senha é usada duas vezes no fluxo de importação, uma para o certificado e outra para a chave.
public function loadFromPfx($pfxData, $password)
{
    $certs = $this->pkcs12Parser->parse($pfxData, $password);
    return $this->x509Parser->parse($certs['cert']);
}

public function extractPrivateKeyFromPfx($pfxData, $password)
{
    return $this->pkcs12Parser->extractPrivateKey($pfxData, $password);
}
```

Mostra que `CertificateManager` não guarda estado: cada chamada reparseia o PFX e o `extracerts` devolvido por `openssl_pkcs12_read` é descartado; só `cert` e `pkey` chegam ao banco/assinador.

```php
// V2/Services/DigitalSignature/DigitalSignatureService.php:43-53 — a única regra de validade
// na importação, com bypass textual pelo e-mail de suporte, e a extração da chave privada em claro.
if (!$certDto->isValidNow() && $userEmail !== config('v2.support.email')) {
    throw new CertificateException(
        'O certificado está expirado ou ainda não está dentro do período de validade. Apenas o suporte técnico pode cadastrar certificados fora do prazo.',
        CertificateException::EXPIRED
    );
}

$privateKeyPem = $this->certificateManager->extractPrivateKeyFromPfx($pfxData, $pfxPassword);

try {
    $encrypted = $this->certificateManager->encryptPrivateKey($privateKeyPem, $userId);
```

O `try` começa **depois** do parsing e da extração: erros de senha/PFX não são reembalados, mas qualquer erro a partir da cifragem vira `CERT_ENCRYPTION_FAILED` com code 500.

```php
// V2/Services/DigitalSignature/Security/PrivateKeyVault.php:23-29 e 49-58 — derivação e cifragem:
// chave de 32 bytes = SHA-256 cru de APP_KEY + literal + user_id; AES-256-CBC com IV aleatório.
protected function deriveUserKey($userId)
{
    $appKey = config('app.key');
    $salt = self::KEY_DERIVATION_PREFIX;

    return hash('sha256', $appKey . '|' . $salt . '|' . $userId, true);
}

// ...
$userKey = $this->deriveUserKey($userId);
$iv = openssl_random_pseudo_bytes(self::IV_BYTES);
$encrypted = @openssl_encrypt($privateKeyPem, self::ENCRYPTION_CIPHER, $userKey, OPENSSL_RAW_DATA, $iv);
```

O `salt` é constante global, não por registro; não há iterações, MAC ou nonce além do IV. A mesma chave derivada vale para todos os certificados do mesmo usuário.

```php
// V2/Services/DigitalSignature/DigitalSignatureService.php:62-85 — formato exato da linha gravada.
$record = array(
    'id' => $id,
    'user_id' => $userId,
    'name' => $name,
    'certificate_pem' => $certDto->certificatePem,
    'private_key_encrypted' => $encrypted['encrypted'],
    'private_key_iv' => $encrypted['iv'],
    // ... serial_number, issuer, subject, common_name, organization, document_number,
    // email, valid_from, valid_until, thumbprint_sha256, signature_algorithm ...
    'is_active' => true,
    'created_at' => $now,
    'updated_at' => $now,
);

$this->certificateRepository->insert($record);
```

`certificate_pem` é o PEM público; `encrypted`/`iv` são base64. Não há coluna de senha, de PFX, de `key_id`/versão de chave nem de cadeia.

```php
// V2/Services/DigitalSignature/Repositories/CertificateRepository.php:17-32 — o escopo de leitura:
// o e-mail de suporte remove o filtro de user_id; todos os outros só veem os próprios registros.
$query = DB::table(self::TABLE_CERTIFICATES)
    ->whereNull('deleted_at')
    ->select([
        'id', 'name', 'common_name', 'organization', 'document_number',
        'serial_number', 'valid_from', 'valid_until', 'thumbprint_sha256',
        'signature_algorithm', 'is_active', 'last_used_at',
        'created_at', 'updated_at',
    ]);

if ($userEmail !== config('v2.support.email')) {
    $query->where('user_id', $userId);
}

return $query->orderBy('created_at', 'desc')->get();
```

A lista não seleciona `certificate_pem` nem as colunas da chave; `getById`, porém, devolve a linha inteira, inclusive `private_key_encrypted` e `private_key_iv`.

```php
// V2/Services/DigitalSignature/Parsers/X509Parser.php:115-127 — extração de CPF/CNPJ:
// quebra o CN em ':' e aceita qualquer segmento cujo conteúdo numérico tenha 11 ou 14 dígitos.
$cn = isset($parsed['subject']['commonName'])
    ? $parsed['subject']['commonName']
    : (isset($parsed['subject']['CN']) ? $parsed['subject']['CN'] : null);

if ($cn !== null) {
    $cnStr = is_array($cn) ? implode(' ', $cn) : $cn;
    $parts = explode(':', $cnStr);
    foreach ($parts as $part) {
        $clean = preg_replace('/[^0-9]/', '', $part);
        if (strlen($clean) === 11 || strlen($clean) === 14) return $clean;
    }
}
```

Não há validação de dígito verificador; o número é o primeiro segmento de 11/14 dígitos encontrado (CN, depois OU, depois UID).

```php
// V2/Services/DigitalSignature/DigitalSignatureService.php:133-142 — a decifragem usa o usuário
// REQUISITANTE, não o dono do certificado; depois resolve o nome exibido.
$privateKeyPem = $this->certificateManager->decryptPrivateKey(
    $certRecord->private_key_encrypted,
    $certRecord->private_key_iv,
    $request->userId
);

$signerName = $certRecord->common_name;
if (empty($signerName)) {
    $signerName = $certRecord->name ?: 'Desconhecido';
}
```

Essa linha explica por que o suporte, embora enxergue certificados de terceiros, recebe `CERT_DECRYPTION_FAILED` ao tentar assiná-los: a chave foi cifrada com `user_id` do dono.

### 2.4 Algoritmos e custos

- **Parsing**: 2× `openssl_pkcs12_read` por importação (cert e chave), mais 1× `openssl_x509_read`/`openssl_x509_parse` e 1× `openssl_x509_fingerprint`; custo dominado pelo PBKDF do PKCS#12 (implementado pelo OpenSSL).
- **Criptografia**: 1× `hash('sha256')` + 1× `openssl_encrypt` AES-256-CBC no import; 1× `hash` + 1× `openssl_decrypt` na assinatura. Complexidade O(tamanho da chave PEM) (~1,7 KB típico).
- **Assinatura**: FPDI importa **todas** as páginas e TCPDF redesenha todas — O(páginas × conteúdo). `openssl_pkcs7_sign` assina o buffer inteiro (O(bytes)); o hash de saída percorre o PDF assinado outra vez (O(bytes)). Não há streaming: o PDF inteiro fica em memória (`$pdfContent`, buffer do TCPDF, `$signedContent`).
- **Banco**: importação = 1 INSERT; assinatura = 1 SELECT (`getById`) + 1 INSERT (log) + 1 UPDATE (`last_used_at`). Não há transação; índices `idx_v2_sl_certificate_id` e `idx_v2_sl_signed_at` cobrem o histórico (`Migrations/2026_07_01_000002_create_v2_signature_logs_table.php:68-72`).
- **Arquivos**: 2 arquivos temporários por assinatura, com `chmod 0600` no input; o output é criado pelo TCPDF e lido de volta para a memória (`PdfSigner.php:86-103,219-228`).
- **Limite de assinatura**: TCPDF reserva `signature_max_length = 11742` bytes para o `/Contents` (`vendor/tecnickcom/tcpdf/tcpdf.php:1286,13492`); um PKCS#7 maior estoura o slot reservado e desalinha o `/ByteRange` (ver LG).

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- É a **única** implementação de assinatura digital e de cofre de chave privada do V2; nenhum outro módulo/serviço lê `v2_digital_certificates` ou chama OpenSSL para assinar (`[C]` busca global por `DigitalSignature|PdfSigner|CertificateManager` só encontra o módulo consumidor).
- É a única unidade que **re-renderiza** um PDF de entrada com FPDI antes de assinar (o `SVC-EXCELIMPORTER` e o `SVC-GALLERY` usam outras bibliotecas e não produzem PDF assinado).
- É a única que usa o e-mail de suporte como **bypass de validade temporal** de credencial, e não apenas como bypass de RBAC.
- É a única que deriva chave criptográfica de `APP_KEY + user_id` em vez de usar `Crypt::encrypt`/`encrypt()` do Laravel. Não há reuso de `V2/Core` para isso.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-SVC-DIGITALSIGNATURE-01 | A senha do PFX nunca é persistida; só a chave privada cifrada e o IV vão ao banco. | Vazamento do banco não entrega a chave em claro sem `APP_KEY` + `user_id`. | `DigitalSignatureService.php:62-83`; `V2/Services/DigitalSignature/Migrations/2026_07_01_000001_create_v2_digital_certificates_table.php:12-17` |
| RN-SVC-DIGITALSIGNATURE-02 | A chave AES é `sha256(APP_KEY + '\|v2-digital-signature\|' + user_id)` em binário. | Trocar `APP_KEY` ou o `user_id` torna a chave indecifrável; não há migração/re-wrap. | `PrivateKeyVault.php:23-28` |
| RN-SVC-DIGITALSIGNATURE-03 | O escopo de certificados é `user_id`, exceto quando `userEmail === config('v2.support.email')`. | Suporte lista/detalha tudo; usuário comum nunca vê registro alheio. | `CertificateRepository.php:28-43` |
| RN-SVC-DIGITALSIGNATURE-04 | A decifragem usa o `userId` do solicitante, não o `user_id` do registro. | Suporte não assina com certificado de terceiro (falha de decifragem). | `DigitalSignatureService.php:133-137` |
| RN-SVC-DIGITALSIGNATURE-05 | A validade é checada duas vezes com implementações diferentes: DTO (strings) no import e X509 (epochs) no sign. | Datas ausentes: import rejeita (não-suporte), sign aceita. | `CertificateDTO.php:121-126`; `X509Parser.php:99-106` |
| RN-SVC-DIGITALSIGNATURE-06 | O `document_hash` registrado é SHA-256 **do PDF assinado**, em maiúsculas. | Não é o hash do PDF original nem o código impresso no selo; muda a cada assinatura. | `DigitalSignatureService.php:164` |
| RN-SVC-DIGITALSIGNATURE-07 | O selo imprime SHA-256 do **PDF original**, em duas linhas de 32 caracteres, e uma URL fixa do ITI. | O código impresso não é verificável no ITI e não pode ser recalculado a partir do PDF assinado. | `PdfSigner.php:164-165`; `LotepGovernmentTheme.php:70-73` |
| RN-SVC-DIGITALSIGNATURE-08 | `signature_algorithm` é gravado como literal `sha256WithRSAEncryption` em toda linha de log. | O log não reflete o algoritmo real do certificado nem o digest usado pelo OpenSSL. | `SignatureLogRepository.php:31`; `V2/Services/DigitalSignature/Migrations/2026_07_01_000002_create_v2_signature_logs_table.php:57` |
| RN-SVC-DIGITALSIGNATURE-09 | O `is_active` não é filtrado em `getById`; só `deleted_at` exclui. | Registro com `is_active=false` e `deleted_at` nulo continua assinável. | `CertificateRepository.php:35-45` |
| RN-SVC-DIGITALSIGNATURE-10 | `extracerts` do PKCS#12 é descartado e `setSignature(..., extracerts='')` não embute intermediárias. | A cadeia não viaja no PDF; validação depende do repositório do leitor. | `CertificateManager.php:30-39`; `PdfSigner.php:209-217` |
| RN-SVC-DIGITALSIGNATURE-11 | O carimbo é desenhado **antes** de assinar. | O selo faz parte do ByteRange assinado; alterá-lo invalida a assinatura. | `PdfSigner.php:155-195,209` |
| RN-SVC-DIGITALSIGNATURE-12 | Não há unique em `thumbprint_sha256`/`serial_number` nem deduplicação no serviço. | O mesmo PFX pode ser importado repetidamente; o consumidor escolhe o primeiro por thumbprint+user. | `Migrations/...000001:79-82`; `CertificadoDigitalCmsController.php:137-141` |
| RN-SVC-DIGITALSIGNATURE-13 | O log de falha é gravado apenas para `SignatureException`, não para `CertificateException`/erros de banco. | Falha de decifragem/validade não aparece em `v2_signature_logs`. | `DigitalSignatureService.php:190-204` |
| RN-SVC-DIGITALSIGNATURE-14 | `deleteCertificate` faz soft delete e preserva o material cifrado. | A chave permanece recuperável no banco após a remoção. | `CertificateRepository.php:58-68` |
| RN-SVC-DIGITALSIGNATURE-15 | Não há `SignerInterface` implementada nem binding de `PdfSigner`. | Substituir o signer exige alterar o tipo do construtor do serviço. | `SignerInterface.php:15-36`; `PdfSigner.php:32`; `DigitalSignatureService.php:26-31` |
| RN-SVC-DIGITALSIGNATURE-16 | `serialToHex()` devolve o serial decimal como veio do OpenSSL quando ele é todo hexadecimal com mais de 4 caracteres. | A coluna `serial_number` quase sempre contém o decimal, não o hexadecimal; não usar como identificador hexadecimal. | `X509Parser.php:146-150` |

### 3.3 Casos-limite

| Caso | Comportamento observado |
|---|---|
| PFX com senha vazia | Rejeitado por `empty($password)` mesmo que o PKCS#12 aceite senha vazia (`Pkcs12Parser.php:29-31`). |
| PFX com certificado mas sem chave | `CERT_LOAD_FAILED` (`Pkcs12Parser.php:46-48`). |
| Certificado ainda não válido | Cai em `CERT_EXPIRED` na importação; o código `CERT_NOT_YET_VALID` existe mas nunca é lançado (`CertificateException.php:22-23`). |
| Certificado sem `validFrom`/`validTo` | `CertificateDTO` devolve `false` (strings vazias) e bloqueia não-suporte; se inserido pelo suporte, o INSERT de `''` em `timestamp` pode falhar e virar `CERT_ENCRYPTION_FAILED`. No sign, `X509Parser` devolve `true`. |
| Certificado expirado pertencente a outro usuário, chamado pelo suporte | Passa a checagem de validade, mas falha em `CERT_DECRYPTION_FAILED` (RN-04). |
| Certificado soft-deleted | `getById` não encontra → `CERT_NOT_FOUND` 404. |
| Certificado `is_active=false` sem `deleted_at` | Continua listado/assinável (RN-09). |
| Importação duplicada | Permitida; duas linhas com o mesmo `thumbprint_sha256`; o consumidor localiza uma delas por `first()` (`CertificadoDigitalCmsController.php:137-141`). |
| PDF com 0 páginas | `SIG_PDF_LOAD_FAILED`. |
| PDF com Object Streams / XRef Streams (PDF 1.5+) | FPDI livre falha; o erro é traduzido para o texto que pede PDF 1.4 (`PdfSigner.php:235-244`). |
| PDF criptografado | Falha no `setSourceFile`; cai no mesmo caminho de erro. |
| PDF com anotações, formulário AcroForm, bookmarks ou assinatura anterior | Não são importados: só o conteúdo das páginas é copiado (`PdfSigner.php:133-149`); a assinatura anterior deixa de existir no output. |
| `pos_page` > total, 0 ou negativo | Usa a última página (`PdfSigner.php:126-128`). |
| `pos_x`/`pos_y` negativos | Não há clamp inferior; o selo pode sair da página (só há clamp superior, `:171-172`). |
| `pos_x`/`pos_y` não numéricos | `SignatureRequest` converte para `null` e o signer usa margem/`calculateStampY` (`SignatureRequest.php:141-143`). |
| Tema desconhecido | Cai no `default` e usa `LotepGovernmentTheme` (`PdfSigner.php:289-292`). |
| `logo_path` ausente em `ProfessionalTheme` | Usa o badge textual `Maxima` / `Gov. Paraíba` (`ProfessionalTheme.php:143-162`). |
| `add_visual_stamp=false` | Nenhum selo é desenhado e `setSignatureAppearance` não é chamado; o TCPDF usa a aparência default (página 1, retângulo `0 0 0 0`), ou seja, assinatura invisível. |
| Falha de INSERT do log após assinatura bem-sucedida | A exceção de banco não é capturada; o PDF assinado existe só em memória e o chamador recebe erro. |
| Falha de `touchLastUsedAt` | Idem: log `success` já gravado, mas o chamador vê exceção. |
| `getSignatureHistory` para certificado alheio | Sem checagem de dono no repositório; o consumidor chama `getCertificate` antes (`CertificadoDigitalCmsController.php:331-337`). Chamadas diretas vazam o histórico. |
| `limit` negativo/zero em históricos | Repassado direto ao `limit()` do query builder, sem validação. |

### 3.4 Armadilhas de reimplementação

1. **`APP_KEY` é parte da chave de dados, não só da sessão.** Qualquer rotação de `APP_KEY` inutiliza todos os `private_key_encrypted`. Não existe coluna de versão de chave nem rotina de re-cifragem; uma reimplementação precisa decidir entre chave por aplicação (e re-wrap) ou KMS.
2. **`app.key` pode vir com prefixo `base64:`.** O código hasheia a string de configuração como está; reimplementar “decodificando o base64” produz chaves diferentes e incompatíveis com os dados existentes (`PrivateKeyVault.php:25-28`; `config/app.php:110`).
3. **O `user_id` na decifragem é o do requisitante.** “Corrigir” isso para o `user_id` do registro muda o comportamento do suporte e permite que suporte assine por terceiros — decisão de segurança, não bug trivial.
4. **A comparação de suporte é exata e case-sensitive.** `Suporte@...` não é suporte; `config('v2.support.email')` vazio não deve ser tratado como “sem suporte”.
5. **Não confundir os três hashes.** Selo = SHA-256 do PDF original; `document_hash` = SHA-256 do PDF assinado; `thumbprint_sha256` = fingerprint X.509. Só o primeiro é impresso; só o segundo é auditado.
6. **`setSignatureAppearance` está com a ordem de argumentos errada** em relação ao TCPDF 6.11.3 — ver `assinatura-pdf.md` §3.2. Reproduzir a chamada “correta” muda o retângulo clicável e o local do widget.
7. **O selo não é um campo de formulário separado**: é HTML rasterizado no conteúdo da página e, depois, a área de assinatura é configurada. Se o selo for redesenhado depois do `setSignature`, a assinatura quebra.
8. **FPDI livre não suporta PDF moderno.** A mensagem de erro do produto é parte do contrato observável; reimplementar com outra biblioteca muda quais PDFs são aceitos.
9. **Não há transação**; repetir a chamada após falha de log pode produzir um segundo PDF assinado com hash diferente e sem linha de auditoria correspondente.
10. **O caminho `logo_path`** de `ProfessionalTheme` aceita caminho absoluto arbitrário; se um chamador direto expuser esse metadado, o TCPDF lê o arquivo local. O `SignatureRequest` não o preenche, mas o `PdfSigner` aceita.
11. **Timezone**: datas de validade e do selo usam `date()` em `America/Fortaleza`; a comparação epoch do `X509Parser` é absoluta. Em fuso com horário de verão, a comparação por string do DTO pode divergir por 1 h.
12. **`document_size` é o tamanho do PDF de entrada**, não do assinado (`SignatureRequest.php:133`; `DigitalSignatureService.php:171`).
13. **`issuer`/`subject` não saem na ordem canônica.** `openssl_x509_parse($resource, false)` usa nomes longos (`commonName`, `organizationName`), mas `formatDN()` ordena por nomes curtos (`CN`, `OU`, `O`...); os campos não previstos caem no segundo laço, na ordem bruta do array (`X509Parser.php:29,153-172`). Não usar essa string para comparação textual; use o thumbprint.

### 3.5 Dívidas e divergências

| Divergência | Documentação/README | Código real | Fonte |
|---|---|---|---|
| “PAdES” | `PdfSigner` docblock diz “PAdES (PDF Advanced Electronic Signature)” e README repete | `/SubFilter /adbe.pkcs7.detached`, sem `/ETSI.CAdES.detached`, DSS ou VRI — é PKCS#7 detached clássico | `PdfSigner.php:19-29`; `vendor/tecnickcom/tcpdf/tcpdf.php:13486-13492` |
| TSA | README lista “TSA (Timestamp)” como próximo passo | Nunca chama `setTimeStamp`; `applyTSA()` do TCPDF 6.11.3 é `@TODO` e devolve a assinatura intacta | `README.md:99-105`; `vendor/tecnickcom/tcpdf/tcpdf.php:13738-13744` |
| Algoritmo | Log e migration gravam `sha256WithRSAEncryption` | `openssl_pkcs7_sign` sem parâmetro de digest; o valor é constante, não derivado | `SignatureLogRepository.php:31`; `vendor/tecnickcom/tcpdf/tcpdf.php:7730-7733` |
| A3 | README: “preparado para extensão futura A3” e `SignerInterface` documenta A3 | `PdfSigner` não implementa a interface; não há `A3TokenSigner` | `README.md:3,95-97`; `PdfSigner.php:32` |
| Suporte administrativo | README: “tem acesso administrativo a todos (para suporte a clientes)” | Lista/detalhe/histórico global, mas assinatura com chave de terceiro falha na decifragem | `README.md:66-69`; `DigitalSignatureService.php:133-137` |
| Dimensões dos temas | Docblocks: Lotep 110×45 mm; Minimal 90×30 mm; Professional 100×35 mm | Código: 105×35, 95×26, 115×33 mm | `LotepGovernmentTheme.php:15-18,24-35`; `MinimalTheme.php:12-14,21-32`; `ProfessionalTheme.php:16-18,39-49` |
| `name` nos metadados | Consumidor envia `'name' => common_name + document_number` | `SignatureRequest` ignora a chave `name`; o serviço sobrescreve com o CN do certificado | `CertificadoDigitalCmsController.php:253-260`; `SignatureRequest.php:131-145`; `DigitalSignatureService.php:145-146` |
| FKs | Migrations do serviço não criam FKs; comentário em `certificate_id` diz “FK” | FKs são adicionadas depois por `SupportConfigs`, com `onDelete('set null')` em colunas `NOT NULL` | `Migrations/...000002:29-30`; `V2/Modules/Support/SupportConfigs/Migrations/2026_07_02_000001_add_v2_foreign_keys.php:34-49` |
| Constantes mortas | `CERT_NOT_YET_VALID`, `CERT_INVALID_PEM`, `SIG_STAMP_FAILED` | Nunca lançadas | `CertificateException.php:22-27`; `SignatureException.php:15-20` |
| Duplicação de `errorCode` | Base e subclasses declaram `private $errorCode` e `getErrorCode()` | O campo da base nunca é lido; subclasses sobrescrevem | `DigitalSignatureException.php:12-34`; `CertificateException.php:29-53` |

### 3.6 Riscos de reimplementação

| Risco | Por que importa | Mitigação mínima na reimplementação |
|---|---|---|
| **Validade** | Duas implementações divergem em datas ausentes e a checagem não cobre revogação; `CERT_EXPIRED` mistura “expirado” e “ainda não válido”. | Unificar em `validFrom/validTo` absolutos, distinguir os códigos e decidir explicitamente o bypass de suporte. |
| **Cadeia** | `extracerts` é descartado e o PKCS#7 não embute intermediárias; leitores podem reportar “cadeia não confiável” mesmo com certificado ICP-Brasil. | Persistir a cadeia do PKCS#12 e passá-la ao signer; validar propósito/IA5 e raiz. |
| **Permissões** | O serviço não autoriza; o bypass é por e-mail e a query de histórico não tem dono. | Manter a autorização no controller e adicionar checagem de dono em `getSignatureHistory`, ou documentar que é API interna. |
| **Auditoria** | Log de falha só cobre `SignatureException`; algoritmo é hardcoded; não grava tema/posição/local/motivo; log e assinatura não são atômicos. | Registrar um evento único com todos os metadados e o status real; usar transação/outbox. |
| **Rotação de chave** | Sem `key_id`/versão, trocar `APP_KEY` perde todas as chaves; sem re-wrap não há rotação. | Adicionar `key_version`/`kid`, KDF com salt por registro e rotina de re-cifragem. |
| **Integridade da chave** | AES-CBC sem MAC permite alteração indetectável do ciphertext (padding oracle depende do oráculo de erro). | Usar AEAD (AES-256-GCM) ou HMAC; não expor distinção detalhada de erro. |
| **Fidelidade do PDF** | Re-render FPDI perde anotações, formulários, bookmarks e assinaturas anteriores; não suporta PDF moderno. | Assinar por incremental update (ex.: TCPDF/FPDI com preservação ou biblioteca dedicada) ou declarar o limite. |
| **Aparência** | Argumentos de `setSignatureAppearance` trocados; selo pode ficar em página/retângulo errados. | Reimplementar com a assinatura correta `(x, y, w, h, page, name)` e testar em leitor. |
| **Retenção** | Soft delete preserva a chave cifrada indefinidamente. | Definir política de expurgo/rotação e apagar o material cifrado no delete. |

### 3.7 Formato armazenado e proteção da chave

| Campo | Conteúdo exato | Sensibilidade | Fonte |
|---|---|---|---|
| `certificate_pem` | PEM X.509 do certificado **folha**, com `-----BEGIN CERTIFICATE-----`/`-----END CERTIFICATE-----`, quebras de linha e base64 do DER, exatamente como devolvido em `$certs['cert']`. As cadeias de `extracerts` não são gravadas. | Público | `Pkcs12Parser.php:31,50`; `DigitalSignatureService.php:66` |
| `private_key_encrypted` | `base64(openssl_encrypt($privateKeyPem, 'aes-256-cbc', K, OPENSSL_RAW_DATA, IV))`: ciphertext binário (múltiplo de 16 bytes, padding PKCS#7), sem cabeçalho, sem MAC, sem tag e sem `key_id`. | Segredo | `PrivateKeyVault.php:49-72` |
| `private_key_iv` | `base64(IV)` de 16 bytes aleatórios (`openssl_random_pseudo_bytes(16)`), 24 caracteres em base64. Único por importação. | Não secreto, mas necessário à decifragem | `PrivateKeyVault.php:50,71` |
| `K` (não persistida) | `hash('sha256', config('app.key') . '\|' . 'v2-digital-signature' . '\|' . $userId, true)`: 32 bytes binários. Não há HKDF, PBKDF2, scrypt, iterações nem salt por registro. | Segredo derivado | `PrivateKeyVault.php:13-14,23-28` |

- A senha do PFX **não é persistida** em nenhuma coluna; o PFX original também não. A senha existe em claro na memória do controller, do serviço e do parser durante a importação; o parser zera apenas sua cópia local (`Pkcs12Parser.php:33`), e o serviço/controller mantêm a variável até o fim da requisição.
- A chave privada em claro existe em memória durante a importação (para cifrar) e durante a assinatura (para o `openssl_pkcs7_sign`); ela não é logada, mas o caminho `openssl` pode manter cópias internas até o GC (`[I]`).
- O mesmo `K` vale para **todos** os certificados do mesmo `user_id`. Comprometer `APP_KEY` + `user_id` decifra todos eles; comprometer o banco sozinho não decifra sem `APP_KEY`. Não há rotação, versão de chave nem re-wrap.
- AES-CBC sem autenticação não detecta alteração deliberada do ciphertext; a única checagem é o padding no `openssl_decrypt`, que falha na maioria das chaves erradas (`PrivateKeyVault.php:106-121`). O erro é exposto ao chamador como `CERT_DECRYPTION_FAILED`, o que é um oráculo de validação de padding.
- O retorno de `getById` inclui `certificate_pem`, `private_key_encrypted` e `private_key_iv`; qualquer código que receba essa linha (inclusive a view de `show`) deve tratá-la como material sensível e nunca serializá-la.

## 4. Integrabilidade

### 4.1 API pública

Sem type hints de retorno (PHP 7.4). Todas as classes são resolvidas por auto-wiring; não há `bind`/`singleton` para este serviço no `V2ServiceProvider` (`[C]` busca por `DigitalSignature` em `V2/Core`, `app/Providers` e `config` não retorna nada).

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `DigitalSignatureService::__construct` | `(CertificateManager, PdfSigner, CertificateRepository, SignatureLogRepository)` | — | — | container Laravel |
| `importCertificate` | `($userId, $userEmail, $pfxData, $pfxPassword, $name = '')` | `CertificateDTO` | `CertificateException` (`LOAD_FAILED`, `INVALID_PASSWORD`, `EXPIRED`, `ENCRYPTION_FAILED`) | `CertificadoDigitalCmsController@store` |
| `getUserCertificates` | `($userId, $userEmail)` | `Collection` de `stdClass` (sem PEM/chave) | — | `@index`, `@signForm` |
| `getCertificate` | `($certificateId, $userId, $userEmail)` | `stdClass` (linha completa, inclui chave cifrada) | `CertificateException` `NOT_FOUND` 404 | `@signDocument`, `@show`, `@destroy` |
| `deleteCertificate` | `($certificateId, $userId, $userEmail)` | `true` | `CertificateException` `NOT_FOUND` | `@destroy` |
| `signPdf` | `(SignatureRequest $request)` | `SignatureResult` (sempre `success=true`) | `SignatureException` (`INVALID_INPUT`, `PDF_LOAD_FAILED`, `PDF_RENDER_FAILED`, `SIGNER_FAILED`, `CERTIFICATE_MISMATCH`), `CertificateException` (`NOT_FOUND`, `EXPIRED`, `DECRYPTION_FAILED`) | `@signDocument` |
| `getSignatureHistory` | `($certificateId, $limit = 50)` | `Collection` | nenhuma checagem de dono | `@show` |
| `getGlobalSignatureHistory` | `($userId, $userEmail, $limit = 100)` — `$userId` é ignorado | `Collection` | `CertificateException` `ACCESS_DENIED` 403 | nenhum consumidor atual |
| `CertificateManager::loadFromPfx` | `($pfxData, $password)` | `CertificateDTO` | `CertificateException` | serviço; consumidor usa `isCertificateValidNow` |
| `CertificateManager::extractPrivateKeyFromPfx` | `($pfxData, $password)` | `string` PEM | `CertificateException` | serviço |
| `CertificateManager::encryptPrivateKey` | `($privateKeyPem, $userId)` | `['encrypted' => base64, 'iv' => base64]` | `CertificateException` | serviço |
| `CertificateManager::decryptPrivateKey` | `($encryptedPrivateKey, $iv, $userId)` | `string` PEM | `CertificateException` | serviço |
| `CertificateManager::isCertificateValidNow` | `($certificatePem)` | `bool` | não lança (retorna `false`) | `@show` |
| `PdfSigner::sign` | `($pdfContent, $certificatePem, $privateKeyPem, array $metadata = [], $addVisualStamp = true)` | `string` binário | `SignatureException` | serviço |
| `PdfSigner::getType` | `()` | `'a1-file'` | — | nenhum |
| `PdfSigner::getStampBuilder` | `()` | `VisualStampBuilder` | — | nenhum |
| `PrivateKeyVault::encrypt` | `($privateKeyPem, $userId)` | `['encrypted','iv']` | `CertificateException` | `CertificateManager` |
| `PrivateKeyVault::decrypt` | `($encryptedPrivateKey, $iv, $userId)` | `string` PEM | `CertificateException` | `CertificateManager` |
| `SignatureRequest::__construct` | `($pdfContent, $pdfFilename, $certificateId, $userId, $userEmail, array $options = [])` | — | — | controller |
| `SignatureRequest::validate` | `()` | `void` | `SignatureException` `INVALID_INPUT` | serviço |
| `SignatureResult::wasSuccessful` | `()` | `bool` | — | controller |
| `SignatureResult::toArray` | `()` | `array` sem o binário | — | nenhum |

`CertificateDTO` e `SignatureResult` são classes de campos públicos, sem getters; `DigitalSignatureService::getUserCertificates` **não** devolve `CertificateDTO` — devolve as linhas cruas do query builder.

### 4.2 Componentes Blade e contrato de UI

Não se aplica: o serviço não tem `Views/`, `Routes/`, `assets` nem `ModuleProvider` (`[C]` o diretório só contém PHP de domínio, migrations e README). A superfície visual é integralmente do módulo `MOD-INFRAESTRUTURA-CERTIFICADODIGITAL`; o serviço apenas fornece `isCertificateValidNow`, as listas e o binário para download.

### 4.3 JS e ciclo de vida

Não se aplica ao serviço. O consumidor usa PDF.js e campos ocultos `pos_x`, `pos_y`, `pos_page`; o `pos_page` é criado por JS com valor `1` e as coordenadas são normalizadas em uma wireframe A4 210×297 mm (`V2/Modules/Infraestrutura/CertificadoDigital/Views/cms/sign/partials/scripts.blade.php:40-57,280-284`). O serviço recebe esses valores já convertidos.

### 4.4 Ordem de inicialização e registro

1. `composer require setasign/fpdi-tcpdf ^2.3` e `tecnickcom/tcpdf ^6.11` — versões travadas em `setasign/fpdi v2.6.8`, `setasign/fpdi-tcpdf v2.3.0`, `tecnickcom/tcpdf 6.11.3` (`composer.json:17-20`; `composer.lock:2257-2258,2343-2344,4226-4228`).
2. Extensões PHP `openssl` (obrigatória) e `curl` (só se TSA fosse usada; não é). `tmp` gravável para os arquivos `v2_sign_*`.
3. `APP_KEY` definido **antes** de qualquer importação; mudanças posteriores quebram a decifragem.
4. `V2ServiceProvider::boot()` carrega as migrations de todo diretório em `V2/Services/*/Migrations` (`V2/Core/Providers/V2ServiceProvider.php:169-184`); as duas tabelas nascem no `php artisan migrate` antes de o módulo consumidor ser usado. O provider também carrega o helper `v2_uuid()` no `register()` (`V2/Core/Providers/V2ServiceProvider.php:342-356`).
5. As FKs reais vêm depois, na migration de `SupportConfigs`, que exige as duas tabelas já criadas (`V2/Modules/Support/SupportConfigs/Migrations/2026_07_02_000001_add_v2_foreign_keys.php:25-49`).
6. O módulo consumidor exige a permissão `CertificadoDigital` e a feature flag `module.active:CertificadoDigital`; o serviço não depende de nenhum dos dois.
7. Não há singleton: cada resolução cria um novo `PdfSigner`/`VisualStampBuilder`, portanto o tema resolvido em uma chamada não vaza para a próxima (mas o objeto `PdfSigner` reutilizado manualmente mantém o último tema até `resolveStampTheme` trocá-lo, `PdfSigner.php:276-295`).

**Pontos de extensão.** O único ponto realmente plugável é o tema: implementar `StampThemeInterface` (`getWidth`, `getHeight`, `getMarginLeft`, `buildHtml`, `getType`) e injetar em `VisualStampBuilder`; `theme_type` precisa ser reconhecido em `resolveStampTheme` ou cai no `default` Lotep. Para um novo signer (A3), seria necessário implementar `SignerInterface` **e** alterar o type-hint de `PdfSigner` no construtor de `DigitalSignatureService`, porque a interface não é usada (`SignerInterface.php:15-36`; `DigitalSignatureService.php:26-31`). `logo_path` no tema `professional` é o único metadado de aparência fora do `SignatureRequest` (`PdfSigner.php:284-287`; `ProfessionalTheme.php:145-150`).

### 4.5 Receitas de uso

**Importar um certificado A1 (consumidor canônico).**

```php
$dto = app(\V2\Services\DigitalSignature\DigitalSignatureService::class)
    ->importCertificate(
        $user->id,
        $user->email,
        file_get_contents($request->file('certificate_file')->getPathname()),
        $request->input('certificate_password'),
        $request->input('certificate_name', '')
    );

// O DTO não traz o id; o consumidor reencontra a linha pelo thumbprint.
$model = DigitalCertificate::where('thumbprint_sha256', $dto->thumbprintSha256)
    ->where('user_id', $user->id)
    ->first();
```

Resultado: linha em `v2_digital_certificates`, chave cifrada, `is_active=true`; `$dto->documentNumber` contém CPF/CNPJ se extraído.

**Assinar e devolver download.**

```php
$request = new \V2\Services\DigitalSignature\DTOs\SignatureRequest(
    file_get_contents($pdfFile->getPathname()),
    $pdfFile->getClientOriginalName(),
    $certificateId,
    $user->id,
    $user->email,
    [
        'location' => 'João Pessoa, Paraíba',
        'reason' => 'Publicação de Diário Oficial',
        'contact_info' => 'admin@lotep.pb.gov.br',
        'add_visual_stamp' => true,
        'theme_type' => 'lotep-government',
        'pos_page' => 1,
    ]
);

$result = $service->signPdf($request);
return response($result->signedPdfContent, 200, [
    'Content-Type' => 'application/pdf',
    'X-Document-Hash' => $result->documentHash,
]);
```

Resultado: PDF re-renderizado e assinado, hash do output, linha `success` no log e `last_used_at` atualizado.

**Verificar validade sem assinar.**

```php
$cert = $service->getCertificate($id, $user->id, $user->email);
$valido = app(\V2\Services\DigitalSignature\CertificateManager::class)
    ->isCertificateValidNow($cert->certificate_pem);
```

Resultado: `bool` baseado em epochs do certificado; não consulta revogação.

**Suporte: listar tudo e usar certificado expirado.**

```php
// $user->email === config('v2.support.email')
$todos = $service->getUserCertificates($user->id, $user->email); // sem filtro de user_id
$historico = $service->getGlobalSignatureHistory($user->id, $user->email, 100);
```

Resultado: todas as linhas (inclusive de terceiros) e histórico global. Assinar com certificado de terceiro falha em `CERT_DECRYPTION_FAILED`, salvo se o certificado pertencer ao próprio suporte.

### 4.6 Anti-uso

**1. Passar a chave privada ou a senha para o DTO/serviço.** `SignatureRequest` não tem campo de chave nem de senha e o serviço sempre resolve a chave internamente pelo `certificateId` + `userId` (`SignatureRequest.php:7-12,22-100`). Criar um parâmetro extra de chave contorna o cofre e faz a chave transitar por controllers/views/logs.

**2. Usar `signPdf` como fronteira de autorização.** O serviço só aplica o filtro `user_id`/suporte; não checa `v2_can`, papel, CSRF ou propriedade do PDF. Chamar `signPdf` de um endpoint sem a checagem `CertificadoDigital:write` permite que qualquer autenticado assine com o próprio certificado — e o histórico global só é barrado pelo e-mail, não por role.

**3. Reutilizar o `SignatureResult` inteiro em JSON.** O objeto carrega o binário do PDF em `$signedPdfContent`; `json_encode($result)` inclui o PDF inteiro (e pode estourar memória/log). Use `toArray()`, que omite o binário (`SignatureResult.php:71-82`).

**4. Assumir que `CertificateManager::encryptPrivateKey($pem, $userId)` é portável entre usuários.** Mover a linha para outro `user_id` sem re-cifrar torna a chave indecifrável; usar o `user_id` errado na decifragem gera `CERT_DECRYPTION_FAILED`.

**5. Tratar `is_active` como sinônimo de disponibilidade.** O serviço ignora a flag; só `deleted_at` bloqueia. Uma reimplementação que filtre `is_active` muda o conjunto de certificados assináveis.

### 4.7 Custos e limites

- **Upload**: o serviço não impõe limite; o consumidor limita o PFX a 5 MB (`max:5120`) e o PDF a 20 MB (`max:20480`) (`CertificadoDigitalCmsController.php:101-117,218-227`).
- **Senha**: `required|string|min:1|max:512` no consumidor; o parser rejeita vazia.
- **PDF**: sem limite no serviço; todo o conteúdo é carregado em memória três vezes (entrada, buffer TCPDF, saída) e depois mais uma para o hash.
- **Assinatura PKCS#7**: TCPDF reserva 11.742 bytes; certificados/chains maiores podem exceder e gerar PDF inválido (LG-SVC-DIGITALSIGNATURE-02).
- **Histórico**: `limit` default 50 (por certificado) e 100 (global); sem paginação/offset. A global varre a tabela inteira ordenada por `signed_at` (índice `idx_v2_sl_signed_at`).
- **Concorrência**: sem lock; duas importações do mesmo PFX criam duas linhas; duas assinaturas do mesmo certificado só disputam o `UPDATE last_used_at` (último vence).
- **Idempotência**: não existe. Repetir `importCertificate` duplica o certificado; repetir `signPdf` gera outro PDF, outro hash e outra linha de log.
- **Throttle/quota**: inexistente no serviço; o limite é o do PHP (`max_execution_time`, `memory_limit`) e o do upload no controller.
- **Tempo**: dominado por OpenSSL (PBKDF do PFX) e FPDI/TCPDF (páginas). Não há timeout interno; o limite é o `max_execution_time` do PHP.

## 5. Rotas e endpoints

Não se aplica: `V2/Services/DigitalSignature/` não tem `Routes/`, e `V2ServiceProvider::mapServiceRoutes()` só registra rotas se existir `Routes/cms.php`, `Routes/web.php` ou `Routes/api.php` (`V2/Core/Providers/V2ServiceProvider.php:203-208`). Não há controller, middleware, throttle, cache ou request HTTP dentro da unidade.

A superfície HTTP que aciona o serviço pertence a `MOD-INFRAESTRUTURA-CERTIFICADODIGITAL` e está documentada naquela unidade: `GET/POST /cms/certificado-digital`, `GET /cms/certificado-digital/create`, `GET/POST /cms/certificado-digital/sign`, `GET /cms/certificado-digital/{id}`, `POST /cms/certificado-digital/{id}/delete` (`V2/Modules/Infraestrutura/CertificadoDigital/Routes/cms.php:14-24`). Todas passam por `CmsAuthenticate`; as actions de escrita exigem `CertificadoDigital:write` dentro do controller, enquanto `index`/`show` não checam permissão. O serviço recebe apenas os dados já validados.

## 6. Dados e migrations

Duas tabelas, ambas com PK `uuid` gerada na aplicação por `v2_uuid()` (não há `DEFAULT` no banco). Nenhuma tem `unique`; as FKs reais são adicionadas fora da unidade.

| Tabela | Papel | Colunas essenciais | Índices | Soft delete | Fonte |
|---|---|---|---|---|---|
| `v2_digital_certificates` | certificado A1 + chave privada cifrada + metadados públicos | `id`, `user_id`, `name`, `certificate_pem`, `private_key_encrypted`, `private_key_iv`, `serial_number`, `issuer`, `subject`, `common_name`, `organization`, `document_number`, `email`, `valid_from`, `valid_until`, `thumbprint_sha256`, `signature_algorithm`, `is_active`, `last_used_at`, timestamps | `idx_v2_dc_user_id`, `idx_v2_dc_thumbprint`, `idx_v2_dc_serial`, `idx_v2_dc_active` | `deleted_at` | `V2/Services/DigitalSignature/Migrations/2026_07_01_000001_create_v2_digital_certificates_table.php:30-83` |
| `v2_signature_logs` | auditoria de cada tentativa de assinatura | `id`, `certificate_id`, `user_id`, `document_hash` (`char(64)`), `document_name`, `document_size`, `signer_name`, `signer_document`, `status`, `error_message`, `signature_algorithm`, `signed_at`, timestamps | `idx_v2_sl_certificate_id`, `idx_v2_sl_user_id`, `idx_v2_sl_document_hash`, `idx_v2_sl_status`, `idx_v2_sl_signed_at` | nenhum | `V2/Services/DigitalSignature/Migrations/2026_07_01_000002_create_v2_signature_logs_table.php:25-73` |

Notas de schema:

- `private_key_iv` é `varchar(255)` mas guarda base64 de 16 bytes (24 caracteres); `thumbprint_sha256` é `varchar(64)`; `document_number` é `varchar(20)` (CPF 11/CNPJ 14).
- `v2_signature_logs.certificate_id` e `user_id` são `NOT NULL`; a migration posterior tenta `onDelete('set null')` nas duas, o que é incompatível com `NOT NULL` em hard delete (`V2/Modules/Support/SupportConfigs/Migrations/2026_07_02_000001_add_v2_foreign_keys.php:34-49`). Na prática só o soft delete do certificado é usado.
- `down()` não apaga: renomeia para `backup_v2_digital_certificates_<YmdHis>` / `backup_v2_signature_logs_<YmdHis>` (`V2/Services/DigitalSignature/Migrations/2026_07_01_000001_create_v2_digital_certificates_table.php:99-112`; `V2/Services/DigitalSignature/Migrations/2026_07_01_000002_create_v2_signature_logs_table.php:89-102`).
- Cardinalidade: 1 usuário → N certificados (sem unique, então o mesmo PFX pode repetir); 1 certificado → N logs; o histórico global cresce sem expurgo e sem paginação.
- Quem escreve: `CertificateRepository::insert/softDelete/touchLastUsedAt` e `SignatureLogRepository::log`; nenhum model Eloquent do serviço. O model `DigitalCertificate` existe só no módulo consumidor para o `ActivityLogger`.
- O detalhamento coluna a coluna está em `_indices/parcial-SVC-DIGITALSIGNATURE/tabelas.csv`.

## 7. Configuração e permissões

| Chave | Default | Env | Efeito | Fonte |
|---|---|---|---|---|
| `app.key` | `null` | `APP_KEY` | Semente da chave AES-256-CBC; entra no `sha256` junto do literal `v2-digital-signature` e do `user_id`. Trocar/rotacionar inutiliza as chaves armazenadas. `APP_KEY` vazio torna a chave derivada previsível. | `config/app.php:110`; `PrivateKeyVault.php:23-28` |
| `v2.support.email` | `suporte@maxima.inf.br` | `V2_SUPPORT_EMAIL` | Bypass de escopo em `getList`/`getById`, de validade na importação e na assinatura, e pré-requisito do histórico global. Comparação exata, case-sensitive. | `config/v2.php:4-6`; `CertificateRepository.php:28,41`; `DigitalSignatureService.php:43,125`; `SignatureLogRepository.php:51-57` |
| `app.timezone` | `America/Fortaleza` | — | `date()` usado nas datas de validade, no nome default `Certificado d/m/Y`, nos timestamps do log e no horário impresso no selo. | `config/app.php:71`; `X509Parser.php:56-58`; `DigitalSignatureService.php:56,60,185` |

- **Permissões**: o serviço não tem middleware nem slug próprio. O consumidor exige `CertificadoDigital:write` em `create`/`store`/`signForm`/`signDocument`/`destroy`; `index` e `show` só exigem `CmsAuthenticate`. A feature flag `module.active:CertificadoDigital` controla o módulo inteiro, não o serviço (`V2/Modules/Infraestrutura/CertificadoDigital/ModuleProvider.php:12`).
- **Não há feature toggle, quota ou throttle** no serviço.
- **Não há permissão de “suporte” separada**: o único critério é o e-mail configurado, não a role Sentinel.

## 8. Observabilidade e erros

- **Logs de aplicação**: apenas dois. `Log::info('[DigitalSignatureService] Certificado removido: ' . $certificateId . ' por usuário ' . $userId)` no delete (`DigitalSignatureService.php:115`) e `Log::error('[DigitalSignatureService] Erro ao importar certificado: ' . $e->getMessage())` quando a persistência falha (`:91`). A assinatura bem-sucedida **não** gera log de aplicação; a falha de assinatura gera linha no banco, não `Log`.
- **Auditoria de domínio**: `v2_signature_logs` registra `success`/`failed`, hash do output, nome e tamanho do PDF de entrada, CN/CPF-CNPJ do signatário e mensagem de erro. Não registra `location`, `reason`, `theme_type`, posição, issuer, serial, nem o IP/usuário autenticado além do `user_id`.
- **Auditoria de atividade**: não é feita pelo serviço; o módulo consumidor usa `ActivityLoggerService` com eventos `created`, `signed_manually` e `deleted` sobre o model `DigitalCertificate` (`CertificadoDigitalCmsController.php:138-155,273-288,378-389`).
- **Níveis/canais**: `Log` padrão do Laravel; não há canal dedicado, nem mascaramento. Nenhuma mensagem imprime senha, chave privada em claro ou IV; o `previous` da exceção pode carregar mensagens do OpenSSL.
- **Códigos**: `getCode()` carrega 400 (default), 404 (`NOT_FOUND`), 403 (`ACCESS_DENIED`), 500 (`ENCRYPTION_FAILED` na importação e `SIGNER_FAILED` no signer genérico). `getErrorCode()` carrega o literal `CERT_*`/`SIG_*`. O consumidor faz redirect com flash `error` para a maioria; `show` converte `NOT_FOUND` em `abort(404)` e `destroy` também verifica `getErrorCode() === NOT_FOUND` antes do redirect.
- **Fail-open/fail-closed**: validade é fail-closed para usuário comum (exceção); para suporte é fail-open explícito. Falha de log após assinatura é fail-closed para o chamador (perde o PDF). Falha de `openssl_x509_read` em `isValidNow` é fail-closed (`false`).
- **Mensagens exatas**: ver §1.3. As mensagens de exceção viram texto de flash no consumidor, com prefixos `Erro no certificado: `, `Erro na assinatura: ` ou `Erro inesperado: ` (`CertificadoDigitalCmsController.php:302-317`).
- **Dados sensíveis**: `certificate_pem` é público; `private_key_encrypted`/`private_key_iv` são sensíveis e trafegam na linha retornada por `getById`. Não há retenção definida para `v2_signature_logs` nem para o material cifrado após soft delete.

**Catálogo completo de exceções.** Todas herdam de `DigitalSignatureException` (que estende `\RuntimeException`), portanto `catch (DigitalSignatureException $e)` captura qualquer falha do serviço (`Exceptions/DigitalSignatureException.php:10`). O `message` é o texto exibido; `getErrorCode()` é o literal estável; `getCode()` é o HTTP code.

| Constante | Lançada em | Efeito para o chamador |
|---|---|---|
| `CERT_LOAD_FAILED` | PFX vazio; PFX sem `cert`/`pkey` (`Pkcs12Parser.php:26-48`) | Importação aborta com `CERT_LOAD_FAILED`/400; nenhum registro. |
| `CERT_INVALID_PASSWORD` | Senha vazia, senha errada ou PKCS#12 inválido (`Pkcs12Parser.php:29-43`) | Importação aborta; a mensagem não distingue com precisão “senha errada” de “arquivo corrompido” quando o OpenSSL não cita PKCS12/password. |
| `CERT_EXPIRED` | Importação ou assinatura fora da validade, sem ser suporte (`DigitalSignatureService.php:43-48,124-131`) | Importação/assinatura aborta; o chamador deve orientar o usuário a renovar o certificado. |
| `CERT_NOT_YET_VALID` | Nunca lançada — reservada (`CertificateException.php:23`) | Nenhum efeito; não usar como sinal de “ainda não válido”. |
| `CERT_INVALID_PEM` | Nunca lançada (`CertificateException.php:24`) | Nenhum efeito. |
| `CERT_ENCRYPTION_FAILED` | Chave vazia, `openssl_encrypt` falso, ou **qualquer** falha de persistência na importação (`PrivateKeyVault.php:42-67`; `DigitalSignatureService.php:90-97`) | Importação aborta; na falha de banco o code é 500 e a mensagem é `Erro ao salvar certificado no banco de dados.`, mesmo que a causa não seja criptográfica. |
| `CERT_DECRYPTION_FAILED` | `private_key_encrypted`/`iv` vazios, base64 inválido, IV/chave errados (`PrivateKeyVault.php:87-121`) | Assinatura aborta antes do PDF; é o erro observado quando o suporte tenta usar certificado de terceiro. |
| `CERT_NOT_FOUND` | `getById` sem linha (inexistente, de outro usuário ou soft-deleted) (`CertificateRepository.php:47-53`) | 404 lógico; importação de assinatura/download aborta. |
| `CERT_ACCESS_DENIED` | `getGlobalSignatureHistory` por e-mail ≠ suporte (`SignatureLogRepository.php:51-57`) | 403 lógico; nenhuma linha retornada. |
| `CERT_PARSE_ERROR` | `openssl_x509_read`/`openssl_x509_parse` falham (`X509Parser.php:24-38`) | Importação aborta; normalmente indica PEM inválido ou certificado malformado. |
| `SIG_PDF_LOAD_FAILED` | Conteúdo vazio, PDF sem páginas, falha de temporário (`PdfSigner.php:66-72,94-100,118-123`) | Assinatura aborta sem tocar o banco (a menos que seja `SignatureException` já dentro do `try` do serviço, que grava `failed`). |
| `SIG_PDF_RENDER_FAILED` | `Output`/`file_get_contents` devolvem vazio (`PdfSigner.php:224-229`) | Assinatura aborta; log `failed` é gravado pelo serviço. |
| `SIG_SIGNER_FAILED` | Default do construtor e wrapper genérico de qualquer `\Exception` do TCPDF/FPDI, com code 500 (`SignatureException.php:28-32`; `PdfSigner.php:235-245`) | Assinatura aborta; mensagem útil para PDF moderno, genérica para o resto. |
| `SIG_CERTIFICATE_MISMATCH` | Certificado ou chave vazios no signer (`PdfSigner.php:74-80`) | Assinatura aborta; indica chamada direta incorreta, não problema do usuário. |
| `SIG_STAMP_FAILED` | Nunca lançada (`SignatureException.php:19`) | Nenhum efeito; falhas de selo caem em `SIG_SIGNER_FAILED`/`\Exception`. |
| `SIG_INVALID_INPUT` | `SignatureRequest::validate()` sem PDF, `certificateId` ou `userId` (`SignatureRequest.php:153-175`) | Nada é gravado nem assinado; o chamador deve corrigir o payload. |

O literal `DIGITAL_SIGNATURE_ERROR` é o default de `DigitalSignatureException`, mas a base não é instanciada diretamente (docblock manda usar as subclasses) (`DigitalSignatureException.php:8-25`).

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| `MOD-INFRAESTRUTURA-CERTIFICADODIGITAL` — `CertificadoDigitalCmsController@index` | `getUserCertificates` | Lista e filtra em memória por `common_name`/`name`/`document_number` (`:52-72`) | Mudar o shape das linhas (ex.: devolver DTO) quebra o filtro e a view. |
| `@create`/`@store` | `importCertificate` | Lê o PFX do upload, chama o serviço, busca o model por thumbprint+user para o ActivityLog (`:87-155`) | Retornar o `id` ou deixar de preencher `thumbprintSha256` quebra o log. |
| `@signForm` | `getUserCertificates` | Alimenta o select de certificados (`:199`) | Idem. |
| `@signDocument` | `new SignatureRequest`, `signPdf` | Valida `mimes:pdf`, `max:20480`, envia opções e devolve o PDF com headers `X-Document-Hash`, `X-Signer-Name`, `X-Signed-At` (`:218-300`) | Mudar nomes de opções (`theme_type`, `pos_*`) ou o tipo de retorno quebra o download. |
| `@show` | `getCertificate`, `getSignatureHistory`, `CertificateManager::isCertificateValidNow` | Detalhe + histórico de 20 + validade (`:331-353`) | `getCertificate` passar a omitir `certificate_pem` quebra a checagem de validade na view. |
| `@destroy` | `getCertificate`, `deleteCertificate` | ActivityLog antes do soft delete (`:367-403`) | Deixar de lançar `NOT_FOUND` muda o 404. |
| `DigitalCertificate` (model do módulo) | tabela `v2_digital_certificates` | `find($id)`, `where(thumbprint,user_id)` para o `ActivityLogger` | Mudar colunas/PK quebra o log e a tela. |
| `SupportConfigs` (migration) | tabelas do serviço | Adiciona FKs depois | Rodar antes das tabelas existirem falha; `set null` em `NOT NULL` falha em hard delete. |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-SVC-DIGITALSIGNATURE-01 | O digest real do PKCS#7 não é parametrizado no código e não foi verificado em um PDF produzido; o log grava `sha256WithRSAEncryption` por constante. | Um PDF pode não atender ao requisito de SHA-256 da ICP-Brasil, e a auditoria afirma um algoritmo que pode não ser o usado. | `vendor/tecnickcom/tcpdf/tcpdf.php:7730-7733`; validar um PDF real com `openssl pkcs7 -print`/`pdfsig`. |
| LG-SVC-DIGITALSIGNATURE-02 | Comportamento quando o PKCS#7 excede `signature_max_length` (11.742 bytes) não é tratado. | O `/Contents` maior que o slot desalinha o `/ByteRange` e pode gerar PDF inválido, sem erro. | `vendor/tecnickcom/tcpdf/tcpdf.php:1286,7705-7747`. |
| LG-SVC-DIGITALSIGNATURE-03 | Não há testes no repositório para o serviço; nenhum vetor de certificado/PFX de exemplo. | Regressões de formato/cadeia/posição não são detectáveis por teste. | `[C]` `grep` em `tests/` não encontra referências. |
| LG-SVC-DIGITALSIGNATURE-04 | A ordem real de execução das migrations em produção (serviço × `SupportConfigs`) não é verificável no código; depende do histórico de deploys. | FK pode não existir em bases antigas ou o rollback pode falhar. | `V2/Modules/Support/SupportConfigs/Migrations/2026_07_02_000001_add_v2_foreign_keys.php:25-49`. |
| LG-SVC-DIGITALSIGNATURE-05 | Não foi possível confirmar se há uso externo (jobs, comandos, integrações) fora do módulo; a busca cobre apenas o repositório. | Um consumidor não mapeado pode depender de `getGlobalSignatureHistory` ou do shape da linha. | `[C]` busca por `DigitalSignature|PdfSigner|CertificateManager` em `V2/`, `app/`, `config/`. |
| LG-SVC-DIGITALSIGNATURE-06 | O comportamento do `openssl_pkcs7_sign` com certificados que não sejam RSA (ECDSA etc.) não é exercitado; o log assume RSA. | Assinatura pode falhar ou ser registrada com algoritmo errado. | `vendor/tecnickcom/tcpdf/tcpdf.php:7730-7733`; `SignatureLogRepository.php:31`. |

## 11. Checklist de paridade

- [ ] `PC-SVC-DIGITALSIGNATURE-01` — Importar PFX válido dentro da validade deve criar exatamente 1 linha em `v2_digital_certificates` com `certificate_pem` não vazio, `private_key_encrypted`/`private_key_iv` em base64 e nenhuma coluna de senha. Fonte: `DigitalSignatureService.php:62-87`; migration `:30-83`.
- [ ] `PC-SVC-DIGITALSIGNATURE-02` — Importar com senha errada deve lançar `CertificateException` com `getErrorCode() === 'CERT_INVALID_PASSWORD'` e mensagem `Senha incorreta ou arquivo PFX/P12 inválido.` (quando o erro do OpenSSL citar PKCS12/password). Fonte: `Pkcs12Parser.php:35-43`.
- [ ] `PC-SVC-DIGITALSIGNATURE-03` — Importar certificado expirado como usuário comum deve falhar com `CERT_EXPIRED`; com `config('v2.support.email')` deve gravar a linha. Fonte: `DigitalSignatureService.php:43-48`.
- [ ] `PC-SVC-DIGITALSIGNATURE-04` — A chave gravada deve ser decifrável apenas com o mesmo `APP_KEY` e o mesmo `user_id`; com outro `user_id` deve lançar `CERT_DECRYPTION_FAILED`. Fonte: `PrivateKeyVault.php:23-28,85-121`.
- [ ] `PC-SVC-DIGITALSIGNATURE-05` — `getUserCertificates` de usuário comum não deve retornar linha de outro `user_id`; com e-mail de suporte deve retornar todas as linhas não deletadas. Fonte: `CertificateRepository.php:17-32`.
- [ ] `PC-SVC-DIGITALSIGNATURE-06` — `signPdf` com PDF válido deve devolver `SignatureResult::wasSuccessful() === true`, `documentHash` com 64 caracteres hexadecimais maiúsculos e gravar 1 linha `status='success'` com `signed_at` preenchido. Fonte: `DigitalSignatureService.php:164-188`.
- [ ] `PC-SVC-DIGITALSIGNATURE-07` — `signPdf` com `pdfContent` vazio deve lançar `SignatureException` `SIG_INVALID_INPUT` com `Conteúdo do PDF não fornecido.`. Fonte: `SignatureRequest.php:153-160`.
- [ ] `PC-SVC-DIGITALSIGNATURE-08` — O PDF assinado deve conter `/SubFilter /adbe.pkcs7.detached` e o carimbo visual deve aparecer na página alvo; anotações/bookmarks/assinaturas anteriores do original não devem sobreviver. Fonte: `PdfSigner.php:133-149,178-195`; `vendor/tecnickcom/tcpdf/tcpdf.php:13486-13492`.
- [ ] `PC-SVC-DIGITALSIGNATURE-09` — Com `add_visual_stamp=false`, o PDF não deve receber o HTML do selo e a assinatura deve ser invisível (aparência default `0 0 0 0`). Fonte: `PdfSigner.php:155`; `vendor/tecnickcom/tcpdf/tcpdf.php:1293`.
- [ ] `PC-SVC-DIGITALSIGNATURE-10` — `deleteCertificate` deve preencher `deleted_at` e `is_active=false`, preservar `private_key_encrypted`, e `getCertificate` posterior deve lançar `CERT_NOT_FOUND`. Fonte: `CertificateRepository.php:35-68`.
- [ ] `PC-SVC-DIGITALSIGNATURE-11` — `getGlobalSignatureHistory` com e-mail ≠ suporte deve lançar `CERT_ACCESS_DENIED` (403); com suporte deve retornar as linhas ordenadas por `signed_at desc` limitadas por `$limit`. Fonte: `SignatureLogRepository.php:49-64`.
- [ ] `PC-SVC-DIGITALSIGNATURE-12` — O log de falha de assinatura deve ter `status='failed'`, `document_hash=''` e a mensagem da exceção; falha de decifragem/validade **não** deve gerar linha. Fonte: `DigitalSignatureService.php:190-204`.
- [ ] `PC-SVC-DIGITALSIGNATURE-13` — Suporte não deve conseguir assinar com certificado de outro usuário (erro de decifragem), embora consiga listá-lo. Fonte: `DigitalSignatureService.php:122-137`.
- [ ] `PC-SVC-DIGITALSIGNATURE-14` — O carimbo deve imprimir 64 caracteres hexadecimais em duas linhas e a URL `https://validar.iti.gov.br`; o nome/local/motivo devem ser escapados com `htmlspecialchars`. Fonte: `LotepGovernmentTheme.php:70-73,75,90`.
- [ ] `PC-SVC-DIGITALSIGNATURE-15` — `v2_signature_logs.signature_algorithm` deve ser sempre `sha256WithRSAEncryption`, independentemente do certificado. Fonte: `SignatureLogRepository.php:31`.

### Checklist de extração (A–M)

| Grupo | Itens cobertos | Onde |
|---|---|---|
| A. Efeito | A1–A7 | §0, §1.1–§1.5 |
| B. Abordagem | B1–B7 | §2.1–§2.4 (diagrama, passo a passo, 7 trechos, custos, falhas) |
| C. Particularidades | C1–C7 | §3.1–§3.5 (fonte única, comparação, RN-*, casos-limite, dívidas) |
| D. Integrabilidade | D1–D9 | §4.1–§4.7 (API, registro/ordem, receitas, anti-uso, custos, consumidores) |
| E. Rotas/HTTP | E1–E7 | §5: não se aplica ao serviço; a superfície HTTP é do módulo consumidor e lá é documentada. |
| F. Dados | F1–F6 | §6 e `tabelas.csv` (colunas, FKs, índices, soft delete, UUID, escritores, cardinalidade 1:N usuário→certificados e 1:N certificado→logs) |
| G. UI CMS | G1–G6 | Não se aplica: o serviço não tem Blade/JS; a UI pertence a `MOD-INFRAESTRUTURA-CERTIFICADODIGITAL`. |
| H. UI Portal | H1–H6 | Não se aplica: nenhuma página pública consome o serviço. |
| I. Contrato de UI/JS | I1–I8 | Não se aplica: não é serviço de UI (não está no conjunto `CONTENTRICHEDITOR`/`GALLERY`/...). |
| J. Config/permissões | J1–J5 | §7; J3 não se aplica (sem `ModuleProvider`), J4 não se aplica (flag é do módulo). |
| K. Observabilidade | K1–K5 | §8 |
| L. Seeds | L1–L3 | Não se aplica: nenhum seeder cria certificados ou logs; as tabelas nascem vazias. |
| M. Paridade/lacunas | M1–M4 | §3.6, §10, §11, §12; lint e cobertura executados. |

## 12. Evidências consultadas

- `V2/Services/DigitalSignature/DigitalSignatureService.php:1-216`
- `V2/Services/DigitalSignature/CertificateManager.php:1-55`
- `V2/Services/DigitalSignature/PdfSigner.php:1-330`
- `V2/Services/DigitalSignature/Security/PrivateKeyVault.php:1-126`
- `V2/Services/DigitalSignature/Parsers/Pkcs12Parser.php:1-67`
- `V2/Services/DigitalSignature/Parsers/X509Parser.php:1-173`
- `V2/Services/DigitalSignature/Repositories/CertificateRepository.php:1-78`
- `V2/Services/DigitalSignature/Repositories/SignatureLogRepository.php:1-65`
- `V2/Services/DigitalSignature/DTOs/CertificateDTO.php:1-149`
- `V2/Services/DigitalSignature/DTOs/SignatureRequest.php:1-180`
- `V2/Services/DigitalSignature/DTOs/SignatureResult.php:1-83`
- `V2/Services/DigitalSignature/Exceptions/DigitalSignatureException.php:1-36`
- `V2/Services/DigitalSignature/Exceptions/CertificateException.php:1-57`
- `V2/Services/DigitalSignature/Exceptions/SignatureException.php:1-43`
- `V2/Services/DigitalSignature/Migrations/2026_07_01_000001_create_v2_digital_certificates_table.php:1-116`
- `V2/Services/DigitalSignature/Migrations/2026_07_01_000002_create_v2_signature_logs_table.php:1-106`
- `V2/Services/DigitalSignature/Signers/SignerInterface.php:1-36`
- `V2/Services/DigitalSignature/Signers/StampThemeInterface.php:1-58`
- `V2/Services/DigitalSignature/Signers/VisualStampBuilder.php:1-104`
- `V2/Services/DigitalSignature/Signers/Themes/LotepGovernmentTheme.php:1-108`
- `V2/Services/DigitalSignature/Signers/Themes/MinimalTheme.php:1-97`
- `V2/Services/DigitalSignature/Signers/Themes/ProfessionalTheme.php:1-202`
- `V2/Services/DigitalSignature/README.md:1-107`
- `V2/Modules/Infraestrutura/CertificadoDigital/Controllers/CertificadoDigitalCmsController.php:1-400`
- `V2/Modules/Infraestrutura/CertificadoDigital/Models/DigitalCertificate.php:1-60`
- `V2/Modules/Infraestrutura/CertificadoDigital/Routes/cms.php:14-24`
- `V2/Modules/Support/SupportConfigs/Migrations/2026_07_02_000001_add_v2_foreign_keys.php:25-49`
- `V2/Core/Providers/V2ServiceProvider.php:169-184`
- `V2/Core/Helpers/helpers.php:54-69`
- `config/app.php:71,110`
- `config/v2.php:4-6`
- `composer.json:17-20`
- `composer.lock:2257-2258,2343-2344,4226-4228`
- `vendor/tecnickcom/tcpdf/tcpdf.php:1286,7705-7747,13486-13492,13607-13645,13678-13703,13738-13744`
