Skip to content

ICP-Brasil digital signature — Koder Signer contract

signing specs/signing/icp-brasil.kmd

Normative contract for the Koder Signer service (`services/crypto/signer/`) covering ICP-Brasil digital signature: supported formats (PAdES, CAdES, XAdES), signature policies (AD-RB, AD-RT, AD-RV), hardware token integration (A3 via PKCS#11), file certificate (A1 PFX) loading, certificate chain validation, timestamp authority (TSA) interaction, and revocation checking (CRL + OCSP). Applies to every Koder component that needs digital signature with legal validity in Brazil (per MP 2.200-2/2001 art. 10 §1º). Other Koder components consume Signer via REST/gRPC, never reimplementing PKI primitives locally (per `policies/reuse-first.kmd`).

When this spec applies

Primary triggers

All triggers

Specification body

Spec — ICP-Brasil digital signature (Koder Signer contract)

Version: 0.1.0 — Draft Status: Proposed (2026-05-13)

Position in multi-jurisdiction architecture (2026-05-20). Per rfcs/signing-RFC-001-multi-jurisdiction.kmd (draft), Koder Signer is designed as a single service issuing signatures under three jurisdictions (BR, EU, US). This spec is the BR profile of that design — it stays normative for everything ICP-Brasil related; sibling profiles (eidas.kmd, esign.kmd) open when their waves begin. Consumers select the jurisdiction per request via ?jurisdiction=br|eu|us; this spec applies when br is selected.

Scope. This spec defines the contract Koder Signer (services/crypto/signer/) exposes for digital signature with legal validity in Brazil. It governs both the internal implementation of Signer and the consumer contract that every other Koder component (Koder Sign, Flow, custom integrations) follows when requesting a signature. The provider side (key generation, HSM integration, root CA store) is covered separately when those sub-components mature.

Legal anchor. MP 2.200-2/2001 (still in force) distinguishes two types of electronic signature in Brazil:

  • Art. 10 §1º — Signatures via ICP-Brasil PKI carry legal validity equivalent to handwritten signature by presumption.
  • Art. 10 §2º — Other forms (drawn, typed, OTP-based) are valid when both parties agree.

Koder Sign (products/horizontal/sign/) currently implements only §2º (drawn/typed + email OTP). This spec covers what is needed for §1º — strict ICP-Brasil compliance.

Art. 6º, parágrafo único — the PRECONDITION of §1º (added 2026-07-16). The anchor above cited only the presumption and never what earns it:

"O par de chaves criptográficas será gerado sempre pelo próprio titular e sua chave privada de assinatura será de seu exclusivo controle, uso e conhecimento."

This omission is load-bearing, not cosmetic. R3.1 requires that the passphrase not be persisted — and reads as compliant because it meets that bar. But "not persisted" was never the requirement.

⚠️ READ THE CORRECTION BELOW BEFORE USING THE NEXT SENTENCE. The original reading was: "exclusive control breaks when the private key crosses the wire at all, persisted or not — at that instant the key has uso e conhecimento on machines the titular does not control." Verification found no norm that says so. It is interpretation, not citation: defensible engineering, unsupported as law. It is kept here because it drove real decisions and deleting it would hide why — not because it is authority.

⚠️ CORRECTED 2026-07-16 (same day) after primary-source verification. The paragraph above was written from an analysis produced from memory by a non-lawyer. An adversarial verification pass (86 agents, every claim required to carry a fetched primary URL + verbatim quote, each re-fetched by a refuter) confirmed the article text character-for-character and refuted the reading built on it. What survives and what does not:

  • VERIFIED verbatim at planalto.gov.br/ccivil_03/mpv/antigas_2001/2200-2.htm (SHA-corroborated against the Câmara/LEGIN publication). The quote above is exact.
  • THE SUMMARY DROPPED A CONJUNCTION. The article imposes two obligations, not one: (1) generation — "o par de chaves será gerado sempre pelo próprio titular"; (2) control — "sua chave privada de assinatura será de seu exclusivo controle, uso e conhecimento". Only (2) was carried here. (1) is the more restrictive half and NOBODY has addressed it — not us, not the verification, not the ITI in any document read. It bites every custody design, including an accredited PSC.
  • REFUTED: "ICP-Brasil forbids a third party from holding the user's key." DOC-ICP-04 item 6.1.1.8 (Res. CG ICP-Brasil nº 179/2020, read at repositorio.iti.gov.br/resolucoes/Resolucao179_DOC-ICP-04.htm) expressly authorizes third-party private-key storage in hardware in two forms: (a) an entity accredited as PSC; (b) corporate solutions with employees' keys in an HSM owned by the institution, on the titular's express agreement with the AC's DPC, "com acesso exclusivo por meio da rede interna". Item 6.2.4.2 (Res. 215/2025) corroborates: an AC may not keep a backup of a titular's signing private key "salvo nos casos em que esta é credenciada como PSC". The ITI accredits, audits and supervises 9 PSCs in operation.
  • NOT LAW: "juris tantum". The words juris tantum / "relativa" / "admite prova em contrário" appear in no primary source read — not in Art. 10 §1º, not in CC/1916 art. 131, not in CC/2002 art. 219. It is doctrinal gloss. It may well be correct; it is not something this spec may state as statutory text.
  • The CC mapping is inferred, not statutory. The caput of CC/1916 art. 131 and CC/2002 art. 219 are byte-identical (verified, diffed: 101 chars, zero divergent codepoints) — but their parágrafos únicos differ, so the identity is of the caput only. And CC/2002 art. 2.046 supplies the rule that remissions transfer to "disposições correspondentes"; it does not name art. 219. The defensible chain is: Art. 10 §1º → CC/1916 art. 131 → CC/2002 arts. 2.045 + 2.046 → art. 219. Citing "Art. 10 §1º + art. 219" directly omits the two links that authorize the jump.

What this means for R3.0's ranking (below): it stands on ENGINEERING judgement, not on a verified prohibition. Rejecting our self-hosted, non-PSC, internet-reachable custody is supported by 6.1.1.8's text (it falls in neither (a) nor (b)). Rejecting third-party custody as a class is not — and that wrong reason would also close the door on R3.3, which is normatively open.

Status of this reading. Owner-ratified architectural position (2026-07-16), not a legal opinion, not written by a lawyer, and now known to be legally unlastreada on its central premise. It binds Koder design decisions and no document may cite it as legal authority. Before any compliance claim a court could read, counsel must answer: the generation half of Art. 6º pu against the current DOC-ICP-04 v8.3 and DOC-ICP-17 (which has never been read); whether 6.1.1.8 (infralegal) can resolve an MP's meaning at all, or is attackable as ultra vires; and the zero jurisprudence found on any of it.


R1 — Supported signature formats

Signer MUST support these output formats:

FormatCarrierUse case
PAdES (PDF Advanced Electronic Signature)PDF documentContracts, certificates, official documents
CAdES (CMS Advanced Electronic Signature).p7s file (detached) or embeddedGeneric binary documents, XML, archives
XAdES (XML Advanced Electronic Signature)XML elementNFe, eSocial, structured government docs

ETSI TS 103 171 / TS 103 173 / TS 103 172 normative reference for PAdES/CAdES/XAdES respectively. ICP-Brasil profile DOC-ICP-15 adds Brazilian-specific OIDs and policy URLs.

PDF Signature Visual representation: required when signature should appear graphically in the document; SHOULD respect specs/koder-app/ visual conventions when rendered by Koder Sign.


R2 — Signature policies

Signer MUST support these ICP-Brasil signature policies (in order of cryptographic strength):

PolicyNameIncludes
AD-RBAssinatura Digital com Referência BásicaSigner cert + chain
AD-RTAssinatura Digital com Referência de TempoAD-RB + qualified timestamp (RFC 3161 TSA)
AD-RVAssinatura Digital com Referência para ValidaçãoAD-RT + complete CRL/OCSP responses for chain

⚠️ CORRECTED 2026-07-16 — this table is format-blind, and that produced a real bug. AD-RV DOES NOT EXIST FOR PAdES. DOC-ICP-15.01 v4.0 §2.1.4, verbatim from the ITI PDF: "assinatura digital com Referências para Validação - AD-RV … este formato é suportado apenas nos padrões CAdES e XAdES, inexistindo representação no PAdES" — stated three times in the document. Corroborated: DOC-ICP-15.03 v9.1 Anexo 2 lists 14 standard policies (5 CAdES + 5 XAdES + 4 PAdES: AD-RB, AD-RT, AD-RC, AD-RA) — no RV among them; the signed LPA_PAdES.der enumerates 18 PAdES policies, none RV; every PA_PAdES_AD_RV_*.der URL → 404 (positive controls → 200).

The policy set is per FORMAT, not global:

FormatPolicies that exist
CAdES / XAdESAD-RB · AD-RT · AD-RV · AD-RC · AD-RA
PAdESAD-RB · AD-RT · AD-RC · AD-RAno AD-RV

The long-term-archival PAdES answer is AD-RC/AD-RA, not AD-RV. signer#025 tracks the Signer offering the impossible combination, and imaging#122 having designed the laudo around it ("AD-RV is the only policy this client asks for").

The policy is encoded as an OID + URL in the signed attributes per DOC-ICP-15.

⚠️ That sentence has been true and unimplemented since this spec was written. The Signer emits no policy identifier at all — so its output is PAdES, not PAdES-ICP-Brasil (R3.4; signer#024). The identifiers were established from primary sources on 2026-07-16 and are recorded in signer#024read them there, do not transcribe an OID from memory. Note id-aa-ets-sigPolicyId = 1.2.840.113549.1.9.16.2.15 is mandatory in all four PAdES profiles (15.03 v9.1 Tabela A.14) and forbidden in the TimeStampToken's SignerInfo (A.16); and the embedded hash MUST be the PA's own internal hash, never the LPA's published hash of it (Anexo 1, Nota 2).

Default: AD-RT (timestamp gives non-repudiation across cert expiry). Caller MAY request AD-RB (lightweight) or — for CAdES/XAdES only — AD-RV (long-term archival). For PAdES long-term archival the caller asks for AD-RC or AD-RA; each profile carries its own mandatory-attribute set and algorithm block, so read the Anexo 2 section for the one you pick rather than assuming AD-RB's applies.


R3 — Key material sources

Signer MUST accept two key sources (R3.1, R3.2). R3.0 governs when each may be used under Koder policy. R3.3 (accredited PSC / "certificado em nuvem") is a verified-to-exist third source that Signer cannot consume today — that gap is why R3.1 gets reached for. R3.4 records that our PAdES is not an ICP-Brasil signature at all until it carries a policy identifier.

R3.0 — Exclusive control decides which source is admissible

⚠️ This ranking is KODER ENGINEERING POLICY, not a restatement of law (corrected 2026-07-16 — see the Legal anchor). It was originally derived from Art. 6º pu on the reading that third-party custody is forbidden. That reading was refuted by DOC-ICP-04 6.1.1.8, which expressly authorizes it in two forms. The ranking survives on engineering grounds — narrower blast radius, fewer parties who could have signed, no unaccredited custody — and because our self-hosted, non-PSC, internet-reachable custody falls in neither authorized form. It does not survive as a claim about what ICP-Brasil permits. Never cite this table as legal authority. The "admissible?" column below means "admissible under Koder policy".

Ranked by whether the titular keeps exclusivo controle, uso e conhecimento:

SourceWhere the private key is usedExclusive controlAdmissible for a signature whose point is non-repudiation by a natural person?
R3.2 — A3 token (client-side)the titular's machine/tokenpreservedyes
local signer (ksigner on the titular's machine)the titular's machinepreservedyes — the document travels to the key
R3.3 — accredited remote/cloud certificatethe provider's HSM, unlocked by the titular's own authenticationpreserved by the accredited regimeyes (once R3.3 exists)
R3.1 — A1 PFX over the wireSigner's memorybroken while the request runsno — transitional only, see below
Signer-as-HSM (key at rest in Koder)Signerbroken by construction, uniformlynever — out of scope (§Out of scope)

R3.1 is not a compliant path for a natural person's signature. It is a narrower and shorter breach than key-at-rest, not compliance: the PFX and its passphrase reach machines the titular does not control, and the titular types that passphrase into a form the vendor controls. It remains in this spec because it is the Signer's mechanical entry point (a service must be able to receive material) and it is admissible for signatures where a legal entity signs through its own infrastructure with its own key and no natural person's non-repudiation is at stake (batch/organizational sealing).

The retention asymmetry — why "ship R3.1 now, fix later" does not hold. An archival timestamp can be added to a signature years later; exclusive control cannot be granted retroactively. A document signed via R3.1 stays signed via R3.1 for its whole retention (20 years for a prontuário). The defect is minted into every artifact and is unfixable afterwards — the same one-way-door reasoning that made AD-RV mandatory over AD-RT (R2).

R3.3 — "Certificado digital em nuvem" (PSC) — VERIFIED to exist, not implemented

Gap, recorded 2026-07-16; identifiers VERIFIED same day (primary sources fetched and adversarially re-fetched — the earlier version of this section deliberately named no documents because we had not read any. We have now read these).

Official terms (ITI FAQ item 20, gov.br/iti/pt-br/acesso-a-informacao/perguntas-frequentes/certificacao-digital): the consumer-facing term is "certificado digital em nuvem""o armazenamento da chave privada do titular em dispositivo criptográfico (HSM) de um dos Prestadores de Serviços de Confiança credenciados pela ICP-Brasil". The entity is the PSC; the normative term is "acesso remoto". "Assinatura remota" is not an ITI term in any source read — do not use it as if it were.

DocumentVerified status
Res. CG ICP-Brasil nº 180, de 20/10/2020 — approves DOC-ICP-17 v2.0In force (index at gov.br/iti/pt-br/assuntos/legislacao/resolucoes). Revoked Res. 132/2017.
DOC-ICP-17.01 v3.0 (IN ITI nº 20, de 23/11/2020) — PSC minimum operational proceduresThe operative technical document. gov.br/iti/pt-br/assuntos/legislacao/instrucoes-normativas/IN_20_2020_DOC_17.01_assinada.pdf. v2.3 is superseded — do not cite it.
Res. CG ICP-Brasil nº 132/2017 (created DOC-ICP-17)REVOKED — do not cite.
DOC-ICP-04 (Res. 179/2020, amended by 212/2025 and 215/2025)items 6.1.1.8 / 6.2.4.2 read and diffed.

The mechanism — this is how "control by regime rather than by key location" actually works (DOC-ICP-17.01 v3.0 §6.1.2, verbatim): "Esse acesso ou comando de exportação às chaves privadas dos usuários deve ser de uso, conhecimento e controle exclusivo do titular, sem a possibilidade de ingresso por outros titulares no mesmo HSM, qualquer funcionário do PSC ou dependentes de outras chaves criptográficas." Plus §7.4.1 (only the titular's authentication may request signature creation) and §6.4.1 / §6.4.3.1 (TLS + OAuth 2.0, RFC 6749/7636, Authorization Code → Access Token → Signature, code_challenge_method: S256).

Two contract facts worth knowing before designing against it:

  • The signature_session scope permits "várias assinaturas em várias chamadas a API"per-signature authentication is NOT mandatory, contrary to what one might assume.
  • "Autorização com Credenciais do Titular" (§6.4.6.3) is an optional PSC service — a given PSC may not offer it. And a PSC may not subcontract user-key storage (DOC-ICP-03 item 2.2.7.4).

STILL NOT ESTABLISHED — carry this, do not launder it away: DOC-ICP-17 itself (the main document) has never been read — only its title, approving resolution and status. Its substantive custody requirements are unknown. And §6.1.2 reconciles only the control half of Art. 6º pu (redefined as exclusive control of access); the generation half — "gerado sempre pelo próprio titular" — is unanswered against a PSC that generates the key in its own HSM. That reconciliation also lives in infralegal acts, which cannot amend an MP.

The naming hazard that hid all this: calling R3.1 "A1 nuvem" (as imaging#122 did, meaning merely "web-first, no hardware") borrows the word from this regime while having none of its properties. "Nuvem" means the key is in an accredited PSC's HSM — not that the PFX was uploaded to us.

Not to be confused with the provider side (Koder generating/holding keys as an accredited authority), which is out of scope. R3.3 is Koder as consumer of a PSC.

Tracked: services/crypto/signer#023 (R3.3 source) and services/ai/imaging#146 (the local-signer path).

R3.4 — "We produce PAdES" is NOT an ICP-Brasil signature (VERIFIED — and we fail it)

DOC-ICP-15 v4.0"Visão Geral Sobre Assinaturas Digitais na ICP-Brasil" (gov.br/iti/pt-br/assuntos/legislacao/documentos-principais) §6.4.2.6 / §6.4.3.8 / §6.4.4.6: a signature is "PAdES-ICP-Brasil" only if it carries an identifier of an approved signature policy, which §4.13 defines as OID + cryptographic hash of the policy.

Measured against origin/master the same day: Koder Signer emits no such identifier. Its only policyOID is the TSA's (RFC 3161 timestamp policy — a different thing); pades.Policy is a Go string enum ("AD-RB"/"AD-RT"/"AD-RV"), an internal label that is never embedded in the document.

So the Signer produces PAdES, and does not produce an ICP-Brasil signature — regardless of custody, and independent of every open legal question above. This is the highest-impact engineering finding of the verification, and the only one that needs no lawyer to act on.

Note also: DOC-ICP-15 is NOT the cloud-signing document (a common and confident error — it is the overview). But the family DOC-ICP-15.01/15.02/15.03 (V.9.1) is alive and governs signature generation and signature policies — a different axis from the PSC docs. A product that produces PAdES/CAdES/XAdES is prima facie in 15.x's scope; 17.x only binds you if you are an accredited PSC.

15.01/15.02/15.03 have never been readlidos em 2026-07-16 (signer#026/#027). O aviso "não citar de memória" continua valendo e foi caro: um comentário no pades.go afirmava, sem fonte, o que o 15.02 proíbe; a afirmação estava certa, mas por não ter citação foi ignorada por uma sessão e o defeito foi shippado. Os PDFs estão em ~/temp/icp-15.03/ (IN022021_DOC_15.02_assinada.pdf, doc15_03.pdf); pdftotext -layout

  • grep leva dois minutos. O que segue é transcrito das tabelas, não inferido.

R3.0 — PAdES: o que cada perfil obriga (DOC-ICP-15.03 v9.1 Anexo 1)

Legenda (Tabela A.1) — ler ANTES de qualquer tabela: ND = Não deve (proibido) · O = Obrigatório · P = Pode (opcional) · R = Recomendável.

⚠️ P é "Pode", não "Proibido". A leitura errada inverte metade das tabelas. É proibido que se escreve ND.

Atributos assinados (Tabela A.14) — proibidos em TODOS os perfis (ND): id-signingTime (o instante vai na entrada M do dicionário de assinatura) e adbe-revocationInfoArchival. Confirmado em dobro pelo DOC-ICP-15.02 §4.1 Tabela 5 ("Uso proibido no padrão PAdES") + Tabela 7. As libs Go adicionam os dois sozinhasdigitorus/pkcs7 e digitorus/pdfsign; ver signer/backend/third_party/README.md.

Atributos não-assinados: ⚠️ a Tabela A.15 está DESCONTINUADA (nota impressa nela: "A tabela A.15 foi descontinuada pela Instrução Normativa 08/2018"; idem a A.17). O corpo continua impresso no PDF, o que a faz parecer viva — não citar como autoridade. As fontes vivas para o mesmo conteúdo:

  • Proibições — DOC-ICP-15.02 §4.2 Tabela 6: countersignature, certificateRefs, revocationRefs, attrCertificateRefs, attrRevocationRefs, certValues, revocationValues, escTimeStamp, archiveTimestamp = "Uso proibido no padrão PAdES".
  • Obrigações por perfil — DOC-ICP-15.03 Anexo 2, que define cada política-padrão em seção própria (§11 AD-RB, §12 AD-RT, §13 AD-RC, §14 AD-RA "BASEADA EM PADES"). É a fonte por política, e portanto a melhor: §5.2.1.1.3 de cada uma dá os não-assinados obrigatórios — id-aa-signatureTimeStampToken para AD-RT/RC/RA.

Dicionários PDF (Tabela A.22) — é AQUI que os perfis se separam:

DicionárioRBRTRCRA
Signature DictionaryOOOO
DSSPPOO
VRIP*P*OO
Document Time-stampPPOO

P* — Nota: "Caso seja utilizado DSS para os formatos RB e RT, deve-se usar o VRI."

Consequência que fecha a dúvida recorrente: AD-RC = AD-RT + DSS + VRI + DocTimeStamp. As "Referências Completas" do AD-RC não são atributo CMS (os de referência são todos ND) — vivem nos dicionários do PDF. Por isso a lista de atributos do artefato do AD-RC é idêntica à do AD-RT, e por isso ler só os atributos leva à conclusão errada de que os dois perfis são a mesma coisa.

Entradas obrigatórias, da fonte por-política (Anexo 2 §5.2.1.1.6) — é isto que implementar:

ExtensãoAD-RCAD-RA
br_ext_dssdssDictionaryType, VRI, Certs, OCSPs ou CRLsos mesmos + PBAD_PolicyArtifacts, PBAD_LpaArtifacts, PBAD_LpaSignatures
br_ext_dssvriDictionaryType, Cert, OCSP ou CRL+ PBAD_PolicyArtifact, PBAD_LpaArtifact
br_ext_mandatedDocTSEntriesType, SubFilter, Contentsidem

O "ou" de OCSPs ou CRLs é normativo e resolve o P* da Tabela A.19: uma das duas basta. O delta AD-RA − AD-RC = 3 entradas no dssDictionary, confirmado por duas fontes independentes (A.19 e Anexo 2 §5.2.1.1.6.2.1).

Entradas do DSS (Tabela A.19 — visão agregada): Type/VRI/Certs = O em todos; OCSPs/CRLs = P com a Nota "DEVEM constar no DSS; usar ambas ao mesmo tempo não é proibido"; PBAD_PolicyArtifacts/PBAD_LpaArtifacts/PBAD_LpaSignatures = P em RB/RT/RC e O em RA — essa é a única diferença RC↔RA nestas tabelas. Entradas do VRI (Tabela A.20/15.02 Tabela 10): Type, Cert, OCSP, CRL; TU e TS não invalidam mas são ignorados na validação, e são mutuamente exclusivos. DocTimeStamp (Tabela A.21): Type=DocTimeStamp, SubFilter=ETSI.RFC3161, Contents — todos O.

Hash da política (Nota 2 do Anexo 1): "O hash da política de assinatura no atributo id-aa-ets-sigPolicyId deve ser o hash interno que está na própria PA e não o hash da PA que se encontra publicada na LPA." — normativo; é o que internal/pades/policy.go implementa (lê o 3º elemento do artefato; não computa, e não usa o sidecar da LPA).

Nota 1 (recomendação não atendida): recomenda-se sigPolicyQualifiers com um único qualificador spuri (URI da PA). Não emitimos — é R, não O. Ticket: signer#028.

Estado (2026-07-16): AD-RB e AD-RT emitem conformes (DSS é P para ambos). AD-RC/AD-RA não são emissíveis — falta o escritor de DSS/VRI/DocTimeStamp, que o pdfsign não tem. Tracked: signer#026.

Tracked: services/crypto/signer#024 (fechado), #026, #027 (fechado).

R3.1 — A1 certificate (file-based)

PKCS#12 (.pfx/.p12) file containing the private key encrypted with a passphrase. Loaded via the API:

POST /v1/sign/pades
Content-Type: multipart/form-data
  document: <PDF>
  cert: <PFX>
  passphrase: <string>
  policy: AD-RT

The passphrase MUST NOT be persisted server-side after the request completes. The PFX MUST be wiped from memory using crypto/subtle-style constant-time zeroing.

R3.2 — A3 hardware token (PKCS#11)

Smartcard or USB token (SafeNet eToken, Watchdata Proxkey, Gemalto IDPrime, Morpho, etc.) accessed via PKCS#11 driver. Two deployment modes:

  • Server-side token: Signer host has the token attached + driver installed. API receives PIN, calls C_Sign via the driver.
  • Client-side token: end user has the token at their machine. Signer generates the hash-to-sign, returns it; client signs with the token; signed hash is returned to Signer for finalization. REQUIRES koder_kit PKCS#11 binding (or koder_web_kit WebAuthn bridge for browser flow).

The PIN MUST NOT be persisted. Failed PIN attempts MUST be rate-limited (token has hardware lockout typically at 3 attempts; Signer should also enforce its own backoff to avoid lockout).


R4 — Certificate chain validation

Signer MUST validate every certificate against the ICP-Brasil chain rooted at the official AC-Raiz (current generation as of 2026: AC-Raiz v5; older v2/v3/v4 trusted for legacy verify-only).

The full chain set is published by ITI; Signer MUST ship with a bundled icp-brasil-chains.pem and refresh it on a schedule (default: daily check + reload). Bundle versioning MUST be recorded in audit logs (which chain version validated which signature).

Required checks per cert in chain:

  • notBefore / notAfter valid for signing time
  • KeyUsage contains digitalSignature (signer cert) or keyCertSign (CAs)
  • ExtendedKeyUsage compatible with intended action
  • BasicConstraints CA-flag correct for chain position
  • Subject DN includes OU=ICP-Brasil for ICP certs
  • Cert is not revoked (see R5)

R5 — Revocation checking

Signer MUST check certificate revocation status before producing a signature, in this order:

  1. OCSP — if cert carries an AuthorityInformationAccess OCSP responder URL, query it. Response cached per ICP-Brasil policy (max 7 days, but typically 1-24h).
  2. CRL — if OCSP unavailable, fall back to CRL listed in CRLDistributionPoints. CRL freshness: max 24h.

Failure modes:

  • Revocation status = revoked → reject; emit KSIGNER-SIGN-3001
  • OCSP+CRL unreachable → soft-fail OR hard-fail based on policy (default: soft-fail for AD-RB, hard-fail for AD-RT/AD-RV)
  • Cert revoked before signing time but signing requested retroactively → reject

For AD-RV, the CRL/OCSP responses MUST be embedded in the signature container for offline verification later.


R6 — Timestamp authority (TSA)

For AD-RT and AD-RV, Signer MUST obtain a qualified timestamp from an ICP-Brasil accredited TSA. Default: ICP-Brasil public TSA at ITI. Custom TSA configurable per deployment.

TSA protocol: RFC 3161 over HTTPS. Signer MUST verify the TSA response signature against the TSA cert (also validated per R4-R5).

Time-stamp policy OIDs are encoded in the signature container.


R7 — Error map

User-facing errors follow specs/errors/user-facing-messages.kmd with the KSIGNER product prefix. Esta tabela é MEDIDA do código (grep -rhoE '"KSIGNER-[A-Z0-9-]+"' --include=*.go), não escrita de memória — a divergência anterior (fantasmas KSIGNER-FMT-*/KSIGNER-SIGN-* que o código nunca emitiu, e famílias inteiras omitidas) custou um cliente mapeado errado (signer#021). Anti-drift durável: koder-spec-audit deve cruzar código↔tabela (signer#036).

Request validation (handler) — KSIGNER-10xx:

CodeCategoryMeaning
KSIGNER-1000..1006requestmultipart/tamanho/campo obrigatório inválidos (entrada malformada). 1006 = campo base64 malformado (assinatura diferida — digest/signature), distinto de 1005 (campo ausente): o cliente conserta coisas diferentes
KSIGNER-1010..1018requestleitura de campos do multipart (document/cert/passphrase)
KSIGNER-1098requesterro interno defensivo (500)
KSIGNER-1099requesterro de request não classificado (400)
KSIGNER-RATE-1001raterate-limit por PFX (429) — carrega Retry-After; um cliente que o ignora não respeita o backoff

Certificado — KSIGNER-CERT-1xxx:

CodeCategoryMeaning
KSIGNER-CERT-1001certPFX/passphrase inválida (ErrInvalidPassphrase)
KSIGNER-CERT-1002certcert expirado (ErrExpired)
KSIGNER-CERT-1003certcert fora da cadeia ICP-Brasil (ErrNotInChain)
KSIGNER-CERT-1004certformato de cert inválido (ErrInvalidFormat) — a versão anterior dizia "KeyUsage incompatible", que o código NÃO emite
KSIGNER-CERT-1005certtipo de chave não suportado (ErrUnsupportedKey)

PAdES / CAdES / XAdES — assinatura por formato:

CodeCategoryMeaning
KSIGNER-PADES-2001formatparse do PDF (ErrParsePDF) — inclui os casos que a spec antiga chamava de FMT-5001/5002, que não existem
KSIGNER-PADES-2002formatfalha ao assinar PDF (ErrSignPDF)
KSIGNER-PADES-2003formatopção/política incompatível (ErrIncompatible)
KSIGNER-PADES-2004formatchecagem de cadeia no PAdES (ErrChainCheck)
KSIGNER-PADES-2005formatassinatura diferida (A3/CSC) não corresponde ao documento (ErrDeferredMismatch, HTTP 409) — os signed attributes reconstruídos no complete não batem com o digest que o prepare emitiu. Requisição bem formada; o que está errado é a relação entre documento e assinatura
KSIGNER-CADES-2101..2104formatCAdES: sign CMS / incompatível / cadeia / embed do TSA
KSIGNER-XADES-2201..2203formatXAdES: sign XML / incompatível / cadeia

Revogação / Timestamp / Token (A3):

CodeCategoryMeaning
KSIGNER-REV-3001revocationcert revogado (ErrRevoked) — recusa correta e definitiva, cliente-facing (signer#022)
KSIGNER-REV-3002revocationOCSP+CRL ambos inalcançáveis, hard-fail (ErrUnreachable)
KSIGNER-REV-3003revocationCRL inválida (ErrInvalidCRL)
KSIGNER-TSA-4001timestampTSA inalcançável (ErrAllEndpointsFailed)
KSIGNER-TSA-4002timestampresposta da TSA inválida (ErrParseResponse)
KSIGNER-TSA-4003timestamphash inválido para a TSA (ErrInvalidHash)
KSIGNER-TSA-4004timestampa TSA respondeu com token sobre um digest diferente do solicitado (ErrDigestMismatch) — aceitar ligaria o carimbo a bytes que nunca submetemos (substituição, seja por TSA comprometida ou MITM). Recusa + failover para o próximo endpoint (signer#036)
KSIGNER-TOKEN-2001tokendriver PKCS#11 não carregável (ErrNoDriver)
KSIGNER-TOKEN-2002tokenslot/token ausente (ErrSlotNotFound)
KSIGNER-TOKEN-2003tokenbackoff de PIN imposto pelo signer (ErrPINBackoff)
KSIGNER-TOKEN-2004tokenlockout de PIN imposto pelo hardware (ErrPINLockout)
KSIGNER-TOKEN-2005tokenfalha ao assinar via token (ErrSign)
KSIGNER-TOKEN-2006tokenPIN inválido (ErrInvalidPin)

⚠️ KSIGNER-TOKEN-* e o internal/p11 não são surfaçados hoje (signer#019); a família existe no código mas nenhuma rota a alcança. A3 client-side é signer#035.

Verificação (ocisign) / eIDAS / Jurisdição:

CodeCategoryMeaning
KSIGNER-VERIFY-1100verifycorpo inválido (ErrInvalidBody)
KSIGNER-VERIFY-1101verifycampo obrigatório ausente (ErrMissingField)
KSIGNER-VERIFY-1102verifyparse (ErrParse)
KSIGNER-EIDAS-1000level?level= fora do RequiredLevels() do perfil (400)
KSIGNER-EIDAS-1001levelnível válido mas não implementado no format layer (501)
KSIGNER-EIDAS-3001trustrequest ades com snapshot LOTL >24h stale (ErrEIDASLOTLStale, 503)
KSIGNER-EIDAS-3002truststore LOTL ainda não bootstrapado (ErrEIDASTrustNotLoaded, 503)
KSIGNER-EIDAS-4001trustcert não está em nenhum TSP set do LOTL (ErrEIDASCertNotQual, 422)
KSIGNER-JURIS-6000jurisdictionnome de jurisdição não suportado (400)
KSIGNER-JURIS-6001jurisdictionjurisdição não implementada (501)
KSIGNER-JURIS-6099jurisdictionerro interno do resolver (500, defensivo)
KSIGNER-PSC-5001providerPSC de certificado em nuvem (VIDaaS/BirdID) indisponível no /v1/sign/pades/cloud — outage/timeout/5xx do provedor (cloudcert.ErrProviderUnavailable, 502). Distinto de PADES-2002: outage retriável do upstream, não falha de assinatura nem culpa do chamador

Removidos (fantasmas que o código nunca emitiu): KSIGNER-FMT-5001, KSIGNER-FMT-5002 (documento corrompido / conflito de posição — saem como KSIGNER-PADES-2001), e KSIGNER-SIGN-3001. Um cliente escrito contra eles esperava erros que nunca chegam.

All error codes localized en-US + pt-BR per policies/language.kmd.


R8 — Multi-tenancy

Signer MUST comply with policies/multi-tenant-by-default.kmd:

  • Every signature request carries koder_user_id (and optional workspace_id).
  • Audit logs include tenant scope (who signed, on whose behalf, for which workspace).
  • Cross-tenant access returns 404, not 403.

Tenant isolation does not apply to ICP-Brasil chain bundles (global, read-only) or TSA cache (global but keyed by hash, no PII).


T1-T6 — Test contract

Every Signer implementation MUST pass:

  • T1 — Valid A1 sign: PFX + correct passphrase → valid PAdES with policy AD-RT; ITI Verificador validates green.
  • T2 — Valid A3 sign (client-side, per R3.2) — ⚠️ NÃO IMPLEMENTADO (signer#019/#035): internal/p11 existe mas não é ligado a nenhuma superfície; A3 é client-side (token com o titular), nunca uma rota server-side. T2 original: mocked PKCS#11 with valid cert + PIN → valid CAdES; verifies against bundled chain.
  • T3 — Reject expired cert: PFX with notAfter < now → error KSIGNER-CERT-1002; no partial output written.
  • T4 — Reject revoked cert: cert in test CRL → error KSIGNER-REV-3001.
  • T5 — Cross-validate with reference signer: same input signed by signer-cli SERPRO and by Koder Signer produces semantically equivalent containers (byte-identical not required; both verify green via openssl cms -verify -policy … and ITI Verificador).
  • T6 — Round-trip: sign → embed in PDF → re-open → extract signature → verify signature is intact, policy is preserved, TSA proof present (for AD-RT).

Negative-path tests:

  • N1 — Tampered document: modify PDF byte after sign → verify detects tamper.
  • N2 — Wrong passphrase: error KSIGNER-CERT-1001; rate-limit applies after 3 attempts in 60s window.
  • N3 — TSA unreachable: AD-RT requested but TSA down → hard-fail with KSIGNER-TSA-4001; no partial signature output.

Out of scope (v0.1.0)

  • Signature visual templates (graphic representation in PDF). Tracked separately when Signer reaches consumer UI integration.
  • ICP-Brasil cross-border interoperability (eIDAS bridge).
  • Long-term archival format LTV / PAdES-LTA — see the roadmap below (0.5.0) and services/crypto/signer#018, which owns the PAdES side for medical retention. (This line said "v0.3" while the roadmap said 0.5.0; the roadmap is the one the tickets track.)
  • Signer-as-HSM (Signer hosting keys directly, not just orchestrating signature against external token/file). Out of scope until KMS Sector (services/crypto/kms/) ships.

Roadmap

PhaseDeliverableTickets
0.1.0This spec; CLI prototype ksigner sign --a1 cert.pfx --policy AD-RB doc.pdfservices/crypto/signer#001-003
0.2.0A3 server-side via PKCS#11; AD-RT timestamp; CRL/OCSP#004-006
0.3.0A3 client-side bridge (koder_kit PKCS#11 binding)#007-008
0.4.0Koder Sign integration (refactor internal/crypto/ to consume Signer)sign#XXX
0.5.0XAdES + NFe profile; PAdES-LTA archival#009+

Open questions

  • Implementation language: Go (consistent with foundation/) vs. reuse JVM lib dss-eu via JNI bridge (mature ICP-Brasil support but adds JVM dependency). Decision deferred to ticket #003.
  • TSA failover: ICP-Brasil has only ~3 public TSAs. Should Signer ship with a configurable fallback list, or fail-open if primary is down? (Affects R6.)
  • Cert chain bundle distribution: ship inside the Signer binary (small, no fetch) vs. fetch on startup from ITI (always fresh, but bootstrap dependency)? Default proposal: ship + daily refresh.

Audit hooks

(Reserved for koder-spec-audit signing once a T1-T6 implementation template exists. Workflow path: .gitea/workflows/audit-signing.yml.)

References