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
- Bootstrap services/crypto/signer/
- Refactor products/horizontal/sign to consume Signer
All triggers
- Implement digital signature with ICP-Brasil legal validity
- Add A3 hardware token (smartcard) support to a Koder component
- Generate PAdES/CAdES/XAdES with policy AD-RB/AD-RT/AD-RV
- Validate ICP-Brasil signed document
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 whenbris 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 conhecimentoon 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:
| Format | Carrier | Use case |
|---|---|---|
| PAdES (PDF Advanced Electronic Signature) | PDF document | Contracts, certificates, official documents |
| CAdES (CMS Advanced Electronic Signature) | .p7s file (detached) or embedded | Generic binary documents, XML, archives |
| XAdES (XML Advanced Electronic Signature) | XML element | NFe, 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):
| Policy | Name | Includes |
|---|---|---|
| AD-RB | Assinatura Digital com Referência Básica | Signer cert + chain |
| AD-RT | Assinatura Digital com Referência de Tempo | AD-RB + qualified timestamp (RFC 3161 TSA) |
| AD-RV | Assinatura Digital com Referência para Validação | AD-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.derenumerates 18 PAdES policies, none RV; everyPA_PAdES_AD_RV_*.derURL → 404 (positive controls → 200).The policy set is per FORMAT, not global:
Format Policies that exist CAdES / XAdES AD-RB · AD-RT · AD-RV · AD-RC · AD-RA PAdES AD-RB · AD-RT · AD-RC · AD-RA — no AD-RV The long-term-archival PAdES answer is AD-RC/AD-RA, not AD-RV.
signer#025tracks the Signer offering the impossible combination, andimaging#122having 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 insigner#024— read them there, do not transcribe an OID from memory. Noteid-aa-ets-sigPolicyId=1.2.840.113549.1.9.16.2.15is 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:
| Source | Where the private key is used | Exclusive control | Admissible for a signature whose point is non-repudiation by a natural person? |
|---|---|---|---|
| R3.2 — A3 token (client-side) | the titular's machine/token | preserved | yes |
| local signer (ksigner on the titular's machine) | the titular's machine | preserved | yes — the document travels to the key |
| R3.3 — accredited remote/cloud certificate | the provider's HSM, unlocked by the titular's own authentication | preserved by the accredited regime | yes (once R3.3 exists) |
| R3.1 — A1 PFX over the wire | Signer's memory | broken while the request runs | no — transitional only, see below |
| Signer-as-HSM (key at rest in Koder) | Signer | broken by construction, uniformly | never — 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.
| Document | Verified status |
|---|---|
| Res. CG ICP-Brasil nº 180, de 20/10/2020 — approves DOC-ICP-17 v2.0 | In 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 procedures | The 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_sessionscope 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 read — lidos 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
grepleva 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 escreveND.
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 sozinhas —
digitorus/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-signatureTimeStampTokenpara AD-RT/RC/RA.
Dicionários PDF (Tabela A.22) — é AQUI que os perfis se separam:
| Dicionário | RB | RT | RC | RA |
|---|---|---|---|---|
| Signature Dictionary | O | O | O | O |
| DSS | P | P | O | O |
| VRI | P* | P* | O | O |
| Document Time-stamp | P | P | O | O |
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ão | AD-RC | AD-RA |
|---|---|---|
br_ext_dss → dssDictionary | Type, VRI, Certs, OCSPs ou CRLs | os mesmos + PBAD_PolicyArtifacts, PBAD_LpaArtifacts, PBAD_LpaSignatures |
br_ext_dss → vriDictionary | Type, Cert, OCSP ou CRL | + PBAD_PolicyArtifact, PBAD_LpaArtifact |
br_ext_mandatedDocTSEntries | Type, SubFilter, Contents | idem |
O "ou" de
OCSPs ou CRLsé normativo e resolve oP*da Tabela A.19: uma das duas basta. O delta AD-RA − AD-RC = 3 entradas nodssDictionary, 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_Signvia 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_kitPKCS#11 binding (orkoder_web_kitWebAuthn 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/notAftervalid for signing time- KeyUsage contains
digitalSignature(signer cert) orkeyCertSign(CAs) - ExtendedKeyUsage compatible with intended action
- BasicConstraints CA-flag correct for chain position
- Subject DN includes
OU=ICP-Brasilfor ICP certs - Cert is not revoked (see R5)
R5 — Revocation checking
Signer MUST check certificate revocation status before producing a signature, in this order:
- OCSP — if cert carries an
AuthorityInformationAccessOCSP responder URL, query it. Response cached per ICP-Brasil policy (max 7 days, but typically 1-24h). - CRL — if OCSP unavailable, fall back to CRL listed in
CRLDistributionPoints. CRL freshness: max 24h.
Failure modes:
- Revocation status =
revoked→ reject; emitKSIGNER-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:
| Code | Category | Meaning |
|---|---|---|
KSIGNER-1000..1006 | request | multipart/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..1018 | request | leitura de campos do multipart (document/cert/passphrase) |
KSIGNER-1098 | request | erro interno defensivo (500) |
KSIGNER-1099 | request | erro de request não classificado (400) |
KSIGNER-RATE-1001 | rate | rate-limit por PFX (429) — carrega Retry-After; um cliente que o ignora não respeita o backoff |
Certificado — KSIGNER-CERT-1xxx:
| Code | Category | Meaning |
|---|---|---|
KSIGNER-CERT-1001 | cert | PFX/passphrase inválida (ErrInvalidPassphrase) |
KSIGNER-CERT-1002 | cert | cert expirado (ErrExpired) |
KSIGNER-CERT-1003 | cert | cert fora da cadeia ICP-Brasil (ErrNotInChain) |
KSIGNER-CERT-1004 | cert | formato de cert inválido (ErrInvalidFormat) — a versão anterior dizia "KeyUsage incompatible", que o código NÃO emite |
KSIGNER-CERT-1005 | cert | tipo de chave não suportado (ErrUnsupportedKey) |
PAdES / CAdES / XAdES — assinatura por formato:
| Code | Category | Meaning |
|---|---|---|
KSIGNER-PADES-2001 | format | parse do PDF (ErrParsePDF) — inclui os casos que a spec antiga chamava de FMT-5001/5002, que não existem |
KSIGNER-PADES-2002 | format | falha ao assinar PDF (ErrSignPDF) |
KSIGNER-PADES-2003 | format | opção/política incompatível (ErrIncompatible) |
KSIGNER-PADES-2004 | format | checagem de cadeia no PAdES (ErrChainCheck) |
KSIGNER-PADES-2005 | format | assinatura 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..2104 | format | CAdES: sign CMS / incompatível / cadeia / embed do TSA |
KSIGNER-XADES-2201..2203 | format | XAdES: sign XML / incompatível / cadeia |
Revogação / Timestamp / Token (A3):
| Code | Category | Meaning |
|---|---|---|
KSIGNER-REV-3001 | revocation | cert revogado (ErrRevoked) — recusa correta e definitiva, cliente-facing (signer#022) |
KSIGNER-REV-3002 | revocation | OCSP+CRL ambos inalcançáveis, hard-fail (ErrUnreachable) |
KSIGNER-REV-3003 | revocation | CRL inválida (ErrInvalidCRL) |
KSIGNER-TSA-4001 | timestamp | TSA inalcançável (ErrAllEndpointsFailed) |
KSIGNER-TSA-4002 | timestamp | resposta da TSA inválida (ErrParseResponse) |
KSIGNER-TSA-4003 | timestamp | hash inválido para a TSA (ErrInvalidHash) |
KSIGNER-TSA-4004 | timestamp | a 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-2001 | token | driver PKCS#11 não carregável (ErrNoDriver) |
KSIGNER-TOKEN-2002 | token | slot/token ausente (ErrSlotNotFound) |
KSIGNER-TOKEN-2003 | token | backoff de PIN imposto pelo signer (ErrPINBackoff) |
KSIGNER-TOKEN-2004 | token | lockout de PIN imposto pelo hardware (ErrPINLockout) |
KSIGNER-TOKEN-2005 | token | falha ao assinar via token (ErrSign) |
KSIGNER-TOKEN-2006 | token | PIN inválido (ErrInvalidPin) |
⚠️
KSIGNER-TOKEN-*e ointernal/p11nã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:
| Code | Category | Meaning |
|---|---|---|
KSIGNER-VERIFY-1100 | verify | corpo inválido (ErrInvalidBody) |
KSIGNER-VERIFY-1101 | verify | campo obrigatório ausente (ErrMissingField) |
KSIGNER-VERIFY-1102 | verify | parse (ErrParse) |
KSIGNER-EIDAS-1000 | level | ?level= fora do RequiredLevels() do perfil (400) |
KSIGNER-EIDAS-1001 | level | nível válido mas não implementado no format layer (501) |
KSIGNER-EIDAS-3001 | trust | request ades com snapshot LOTL >24h stale (ErrEIDASLOTLStale, 503) |
KSIGNER-EIDAS-3002 | trust | store LOTL ainda não bootstrapado (ErrEIDASTrustNotLoaded, 503) |
KSIGNER-EIDAS-4001 | trust | cert não está em nenhum TSP set do LOTL (ErrEIDASCertNotQual, 422) |
KSIGNER-JURIS-6000 | jurisdiction | nome de jurisdição não suportado (400) |
KSIGNER-JURIS-6001 | jurisdiction | jurisdição não implementada (501) |
KSIGNER-JURIS-6099 | jurisdiction | erro interno do resolver (500, defensivo) |
KSIGNER-PSC-5001 | provider | PSC 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 optionalworkspace_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/p11existe 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 → errorKSIGNER-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 SERPROand by Koder Signer produces semantically equivalent containers (byte-identical not required; both verify green viaopenssl 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
| Phase | Deliverable | Tickets |
|---|---|---|
| 0.1.0 | This spec; CLI prototype ksigner sign --a1 cert.pfx --policy AD-RB doc.pdf | services/crypto/signer#001-003 |
| 0.2.0 | A3 server-side via PKCS#11; AD-RT timestamp; CRL/OCSP | #004-006 |
| 0.3.0 | A3 client-side bridge (koder_kit PKCS#11 binding) | #007-008 |
| 0.4.0 | Koder Sign integration (refactor internal/crypto/ to consume Signer) | sign#XXX |
| 0.5.0 | XAdES + NFe profile; PAdES-LTA archival | #009+ |
Open questions
- Implementation language: Go (consistent with foundation/) vs.
reuse JVM lib
dss-euvia 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
meta/docs/stack/specs/errors/user-facing-messages.kmdmeta/docs/stack/policies/self-hosted-first.kmdmeta/docs/stack/policies/reuse-first.kmdmeta/docs/stack/specs/multi-tenancy/contract.kmdmeta/docs/cryptography/compendium/05-assimetrica.mdmeta/docs/cryptography/compendium/14-koder-aplicada.mdhttps://www.iti.gov.br (ICP-Brasil authority)https://www.gov.br/iti/pt-br/assuntos/certificado-digital (technical norms)