Digital signature¶
The xmldsig package
implements the XML Signature profile adopted by the Receita Federal. The profile
is narrow and fixed — and that is precisely what makes it interoperable.
The SEFAZ profile¶
| Item | Value |
|---|---|
| Type | Enveloped |
| Reference | Exactly one, pointing at the Id attribute of the signed block |
| Canonicalisation | Canonical XML 1.0 without comments |
| Digest | SHA-1 |
| Signature | RSA with PKCS#1 v1.5 |
| Key | The signer's certificate embedded in X509Data |
| Position | Last child of the element containing the signed block |
In the NF-e and NFC-e the signed block is infNFe, and Signature goes in as
the last child of NFe — after infNFeSupl, when that exists.
<NFe xmlns="http://www.portalfiscal.inf.br/nfe">
<infNFe Id="NFe43260312345678000195550010000000571102030403" versao="4.00">…</infNFe>
<infNFeSupl>…</infNFeSupl>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">…</Signature>
</NFe>
Signing¶
The shortest path goes through the document itself:
When you need to control the steps — for instance, to fill in the NFC-e QR Code between preparation and signing:
if err := n.Preparar(); err != nil {
return err
}
if err := nfce.PreencherSuplemento(n, nfce.Opcoes{CSC: csc}); err != nil {
return err
}
documento, err := n.XML()
if err != nil {
return err
}
assinada, err := xmldsig.Assinar(documento, "infNFe", cert)
To sign every invoice in a batch you have already assembled:
And for other documents that follow the same mechanics, just change the tag name:
Why the original bytes are preserved¶
This is the part that causes the most pain in NF-e libraries, and the mechanism is worth understanding.
The digest is computed over the canonical form of infNFe. If the library
re-serialised the document after signing — reordering an attribute, turning
<a/> into <a></a>, changing how a character is escaped — the digest SEFAZ
recomputes on arrival would no longer match the recorded DigestValue, and the
invoice would be rejected with "invalid signature".
GoNFE avoids that by not re-serialising anything. The signature is inserted into the original bytes, immediately before the parent element's closing tag:
The position comes from the parser itself, which records the offset of every closing tag while reading. Everything before the signature stays byte for byte identical, including accents, entities and formatting.
Canonicalisation¶
C14N 1.0 canonicalisation is implemented in an internal package, over an XML
tree that preserves the original namespace prefixes — something the standard
library's encoding/xml discards.
The implementation is checked against cases 3.2 and 3.3 of the W3C Canonical XML 1.0 specification, which cover whitespace preservation, empty-tag normalisation, attribute ordering and namespace declaration propagation.
Known limitations, all irrelevant to fiscal XML: comments are discarded (the NF-e uses the without comments variant), DTDs and external entities are not supported, and the input must be UTF-8.
Verifying¶
Verificar redoes the whole calculation: it recomputes the digest of the
referenced element, re-canonicalises SignedInfo and checks the signature with
the public key of the embedded certificate.
if err := xmldsig.Verificar(documento); err != nil {
return fmt.Errorf("documento adulterado ou mal assinado: %w", err)
}
It is useful for checking XML received from partners before importing it, and for testing your own signing stack.
Cryptographic verification, not trust verification
Verificar proves the document was not altered after signing and that
whoever signed held the private key of the embedded certificate. It does
not validate the ICP-Brasil chain, nor consult revocation lists. For
that, extract the certificate with xmldsig.Certificado(documento) and
validate it with x509.Certificate.Verify against the ICP-Brasil roots.
Errors¶
The errors are sentinels, comparable with errors.Is:
| Error | Meaning |
|---|---|
ErrElementoNaoEncontrado |
The given tag does not exist in the document |
ErrSemAtributoId |
The element to sign has no Id attribute |
ErrJaAssinado |
A Signature already exists at the target position |
ErrSemAssinatura |
The document being verified is not signed |
ErrAssinaturaInvalida |
The digest or the signature does not check out |
ErrAlgoritmoNaoSuportado |
The document uses an algorithm outside the SEFAZ profile |
A3, HSM and remote signing¶
xmldsig.Assinar does not ask for a *certificado.Certificado — it asks for an
interface:
type Assinante interface {
Sign(aleatorio io.Reader, resumo []byte, opcoes crypto.SignerOpts) ([]byte, error)
DER() []byte
}
Sign is the standard crypto.Signer
signature, and DER returns the signer's X.509 certificate. Anything that can
produce a PKCS#1 v1.5 signature over a SHA-1 digest will do: a PKCS#11 driver
for an A3 token, a cloud KMS client, a network HSM, or an internal signing
service.
type assinanteA3 struct {
sessao *pkcs11.Session
cert *x509.Certificate
}
func (a *assinanteA3) Sign(_ io.Reader, resumo []byte, _ crypto.SignerOpts) ([]byte, error) {
return a.sessao.SignPKCS1v15SHA1(resumo)
}
func (a *assinanteA3) DER() []byte { return a.cert.Raw }
// and then
assinada, err := xmldsig.Assinar(documento, "infNFe", &assinanteA3{...})
The library core stays free of CGO; the native dependency is confined to your adapter.
TLS authentication is still a separate matter
Signing the XML and the TLS handshake use the same key, but through
different paths. With A3, besides the Assinante, you need a
tls.Certificate whose PrivateKey implements crypto.Signer pointing at
the token, and to pass it in sefaz.Config.TLS.