Sign
The SII validates every document and envelope with an enveloped XML signature (XMLDSig) made with the digital certificate of whoever signs. loadCertificateFromPkcs12 and loadCertificateFromPem turn your certificate into a plain Certificate object, signDocument signs a stamped document with it, and verifyDocumentSignature checks a signed <DTE>. verifyReceivedDocuments checks what a supplier sends: the signature, the stamp and the CAF of each document. Underneath, a handful of low-level functions build and verify the signature for any element, which the envelopes, the books and the SII client reuse.
Everything on this page lives in the dte-sdk entry point and runs on Node, Bun and Deno. It reads PKCS#12 and signs with node-forge, one of the four runtime dependencies of the SDK.
A certificate holds a private key. Keep the .pfx file and its password out
of the repository and out of logs; read the password from the environment
(process.env.PFX_PASSWORD!). A loaded Certificate is plain data and holds
the private key in privateKeyPem: never serialize it (JSON.stringify)
into a log or a response.
Signatures are tested against synthetic data and verify with this SDK's own verifier, but no signed document has been sent to the real SII yet. See Status.
Usage#
import { readFileSync } from 'node:fs'
import {
createDocument,
loadCertificateFromPkcs12,
parseCaf,
signDocument,
stampDocument,
verifyDocumentSignature,
} from 'dte-sdk'
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)
certificate.rut // '12345678-5', the holder RUT read from the certificate
certificate.notAfter // Date: when it expires
const caf = parseCaf(readFileSync('caf-33.xml', 'utf8'))
const stamped = stampDocument(createDocument(input), caf)
const signed = signDocument(stamped, certificate)
signed.xml // the signed <DTE>, with its TED and its Signature, no XML declaration
signed.signedAt // '2026-11-15T15:30:00', Chile time (TmstFirma)
signed.signature // { digestValue, signatureValue }
verifyDocumentSignature(signed.xml)
// { referenceId: 'DTE_T33F7', digestValid: true, signatureValid: true, valid: true }Load a certificate#
From a PKCS#12 file#
loadCertificateFromPkcs12(bytes, password, options?) reads a .pfx or .p12 file. bytes is a Uint8Array (a Node Buffer is one).
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)It reads both generations of the format: legacy files (3DES and RC2) and modern ones (PBES2 with AES), with encrypted or plain key bags. It finds the leaf certificate by matching its public key with the private key, orders the chain from the leaf up to the root (the SII TLS endpoints need the intermediates), and reads the holder RUT from the certificate.
From PEM#
loadCertificateFromPem({ privateKeyPem, certificatePem, chainPem? }, options?) builds the same object from strings, for example after you converted a .pfx with openssl pkcs12 -nodes:
| Field | Meaning |
|---|---|
privateKeyPem | The private key, PKCS#1 (RSA PRIVATE KEY) or PKCS#8 (PRIVATE KEY), unencrypted. |
certificatePem | The leaf certificate. |
chainPem | The full chain for TLS, leaf first. Defaults to the leaf certificate. |
It checks that the private key matches the certificate (CERT_INVALID when it does not) and that the certificate is valid now: not past its notAfter (CERT_EXPIRED) and not before its notBefore (CERT_NOT_YET_VALID). loadCertificateFromPkcs12 makes the same two checks.
Options#
Both loaders take LoadCertificateOptions:
| Option | Meaning |
|---|---|
now | Clock for the validity checks (tests). |
allowExpired | Load a certificate outside its validity period instead of throwing CERT_EXPIRED (expired) or CERT_NOT_YET_VALID (not valid yet). For verifying old data. |
The Certificate type#
| Field | Meaning |
|---|---|
privateKeyPem | The private key in PEM. Secret. |
certificatePem | The leaf certificate in PEM (used to sign XML). |
chainPem | Leaf, intermediates and root in PEM (TLS client authentication). Equals certificatePem when there is no chain. |
certificateBase64 | The leaf in DER, base64 (the X509Certificate of the signature). |
publicKey | { modulus, exponent } in base64 (the RSAKeyValue of the signature). |
rut | The holder RUT in SII shape (12345678-5), or null when the certificate has none. |
commonName | The common name (CN), or null. |
subject | The subject fields by short name (CN, O, OU, serialNumber, E, C…). |
notBefore | Date the validity starts. |
notAfter | Date the validity ends. |
The RUT is read from the certificate, in this order: the SubjectAltName otherName with OID 1.3.6.1.4.1.8321.1 (the one the Chilean electronic signature law defines), then the serialNumber of the subject, then the OU, then a token with the shape of a RUT in the CN. Each candidate has to pass rut.ts; an invalid one falls through to the next. That is the RUT you use as senderRut in an envelope cover, because the SII expects the sender to be the holder of the signing certificate. See Envelopes.
Expiry#
import { daysUntilExpiry, isCertificateExpired } from 'dte-sdk'
daysUntilExpiry(certificate) // 412, whole days; negative once expired
isCertificateExpired(certificate) // false
isCertificateExpired(certificate, { marginDays: 30 }) // true when it expires within 30 daysisCertificateExpired(certificate, { now?, marginDays? }) is true when now is past notAfter minus marginDays (default 0). daysUntilExpiry(certificate, { now? }) is floor((notAfter - now) / one day). Check the margin at startup so a certificate that expires next week is noticed before it breaks an issuing run.
Errors#
| Code | When |
|---|---|
CERT_PASSWORD_WRONG | The PKCS#12 password is wrong. No password text appears in the error. |
CERT_INVALID | The file is not a PKCS#12, has no private key or no certificate, the PEM cannot be read, or the key does not match the certificate. details.reason says which. |
CERT_EXPIRED | The certificate is past its notAfter (details.expiresAt). Pass allowExpired: true to load it anyway. |
CERT_NOT_YET_VALID | The certificate's notBefore is still ahead of now (details.validFrom), for example one issued for a later date. Pass allowExpired: true to load it anyway. |
import { isDteError } from 'dte-sdk'
try {
loadCertificateFromPkcs12(bytes, process.env.PFX_PASSWORD!)
} catch (error) {
if (isDteError(error) && error.code === 'CERT_PASSWORD_WRONG') {
// ask for the password again, do not retry in a loop
}
}Sign a document#
signDocument(stampedDocument, certificate, options?) renders the document XML with its TED, signs the Documento element (the Liquidacion element for a settlement invoice, 43) and returns a SignedDocument: the stamped document plus:
| Field | Meaning |
|---|---|
xml | The signed <DTE> XML. It has no XML declaration (the envelope adds it). |
signedAt | TmstFirma, Chile time, written inside the signed element. |
signature | { digestValue, signatureValue }: the SHA-1 digest in base64 and the RSA signature in base64, on one line. |
| Option | Meaning |
|---|---|
now | Clock for TmstFirma. |
signedAt | TmstFirma exactly as given, instead of the Chile time of now. |
The input must be a StampedDocument (see Stamp); that is checked at compile time, so you cannot sign a document without a TED by mistake. The signature is deterministic: the same document, certificate and times give the same bytes.
The signed document is what goes into an envelope (Envelopes); a signed <DTE> on its own is not sent to the SII.
Verify a signature#
verifyDocumentSignature(xml) verifies the signature of a <DTE> XML, yours or one you received, and returns a SignatureVerification:
| Field | Meaning |
|---|---|
referenceId | The ID the signature points to (DTE_T33F7), or null when SignedInfo has no reference, or more than one. |
digestValid | SignedInfo holds one Reference, with SHA-1 and no transform but enveloped-signature or C14N 1.0, and the element it points to is unique, is a direct child of the root next to Signature, has the expected tag and still hashes to DigestValue. |
signatureValid | SignedInfo declares C14N 1.0 and RSA-SHA1, and SignatureValue verifies over it with the certificate embedded in KeyInfo. |
valid | Both checks passed. |
verifyDocumentSignature(signed.xml).valid // true
verifyDocumentSignature(signed.xml.replace('<MntTotal>119000</MntTotal>', '<MntTotal>1</MntTotal>')).valid // falseA tampered element, a missing reference, a duplicated ID or an element moved elsewhere in the tree gives digestValid: false; it does not throw, so wrapped or duplicated elements never verify. It throws XML_PARSE_FAILED when the XML is malformed, nested deeper than 100 levels (the limit of every DOM the SDK parses) or the root is not DTE, and SIGNATURE_NOT_FOUND when there is no Signature under the root or no SignedInfo in it.
Only a signature of the form the SDK makes verifies: one Reference holding the only DigestValue, SHA-1, no transform but enveloped-signature or C14N 1.0, and C14N 1.0 with RSA-SHA1 in SignedInfo. A second Reference, another digest or another transform gives digestValid: false; another canonicalization (exclusive C14N, for one) or another signature method gives signatureValid: false. Before 0.4.0 the verifier checked only the first Reference and did not read the algorithms. The signatures the SDK makes pass as before.
valid proves that the element and SignedInfo are intact with respect to the certificate that travels in the same XML. It does not prove that this certificate belongs to the signer you expect, nor that it is valid with a certification authority: that is a separate check, and the SII does its own. Verify the stamp separately with verifyTed (Stamp), or check a received document whole with verifyReceivedDocuments (below).
The function needs the XML whose root is <DTE>: for a document inside an envelope, use the xml of each signed document, as it is in envelope.documents.
Verify received documents#
verifyReceivedDocuments(xml, options?) checks what a supplier sent you, as it arrived: a DTE, an EnvioDTE or an EnvioBOLETA. It checks the envelope's own signature and, for each document, its signature, its stamp and the CAF the stamp carries, and returns every check as data.
Not run on a document a supplier sent yet. The tests sign test CAFs with a test SII key pair. The two SII keys were checked once by hand, outside the tests: key 100 verifies the CAF of the example the SII publishes, and key 300 verified the production CAFs carried by four real documents. See Status.
import { readFileSync } from 'node:fs'
import { decodeLatin1, isClaimDocumentType, verifyReceivedDocuments } from 'dte-sdk'
// Decode the bytes with the encoding the XML declaration names: ISO-8859-1 for the SII's documents.
// Production by default: a document stamped with a certification CAF comes back invalid.
const { envelope, documents, status } = verifyReceivedDocuments(decodeLatin1(readFileSync('EnvioDTE.xml')))
status // 'valid' | 'invalid' | 'unchecked': the worst of the envelope and its documents
envelope?.valid // the envelope's own signature; envelope is null for a single DTE
documents[0] // { type: 33, folio: 1234, issuerRut: '10000004-0', totalAmount: 119000, signature, ted, caf, status, xml, … }
// The documents you can accept or claim in the SII's registry
const toAnswer = documents.filter((doc) => doc.status === 'valid' && isClaimDocumentType(doc.type))Each document is checked three ways:
| Check | What must hold |
|---|---|
signature | Its XMLDSig signature over Documento (or Liquidacion), with found, digestValid and signatureValid. As with verifyDocumentSignature, it proves the document is intact with respect to the certificate it carries, not whose certificate that is. |
ted | The stamp. Its FRMT verifies with the key of the CAF the TED carries, over the DD as ISO-8859-1 bytes, and its DD agrees with the header: mismatches names what differs among the issuer, the type, the folio, the date, the receiver and the total. RSR and IT1 are free text each issuer folds its own way, and are not compared. |
caf | The CAF in the stamp, against the document (mismatches: another issuer, another type, a folio outside its range) and against the SII: siiSignature checks its FRMA, the SII's signature over the DA, with the SII key its IDK names. |
A document's status is invalid when a check fails, unchecked when none fails but the CAF's IDK has no known key, and valid otherwise; the status of the whole result is the worst of the envelope and the documents. A document that fails a check is a result, not an error. The function throws only for input it cannot read: XML_PARSE_FAILED for malformed XML, a DOCTYPE, another root, an envelope without exactly one SetDTE, a prefixed DTE or Documento, a DTE without its Documento, or elements nested deeper than 100 levels, and ENVELOPE_EMPTY for an envelope without documents. Before 0.7.0 a file of a few thousand nested elements overflowed the stack with a RangeError; no document the SII or an issuer writes comes close to 100.
The SII's keys and the environment#
SII_CAF_KEYS holds the keys 100 (certification) and 300 (production) with which the SII signs every CAF, taken from the SII's certificates as CryptoSys and LibreDTE publish them, the same bytes in both. Only the key of environment is accepted, production by default: a CAF signed with the other one is invalid, with the mismatch environment, and caf.environment says which it is, so a certification document never passes for a production one. Pass environment: 'certification' to check documents of the certification environment. siiKeys replaces the key material; spread SII_CAF_KEYS into it to add a key. cafEnvironment(keyId) says which environment an IDK is for: certification for 100, production for 300, null for another.
verifyCafSignature(cafXml, { environment }) checks the FRMA of a single CAF by the same rule and returns 'valid', 'invalid' or 'unchecked'. To check the CAF your own TEDs will carry, pass it the block parseCaf keeps:
import { readFileSync } from 'node:fs'
import { parseCaf, verifyCafSignature } from 'dte-sdk'
const caf = parseCaf(readFileSync('caf-33.xml', 'latin1')) // CAF files are ISO-8859-1
verifyCafSignature(caf.xml, { environment: 'certification' }) // 'valid' for a CAF the SII issued in certificationIt throws CAF_INVALID when the text is not XML, holds a DOCTYPE, or has no single CAF with one DA and one FRMA. An FRMA that is there but empty is a signature that fails, not a CAF that is not one: it is invalid, whatever the CAF's IDK, as an FRMA that is not base64. Before 0.7.0 it threw CAF_INVALID, and in verifyReceivedDocuments such a CAF was no CAF, so the stamp, left without its key, failed too. Now the CAF is found and fails its SII signature, and the stamp is checked with the CAF's key like any other.
What is checked, and what is not#
- What is checked is what is reported. Each document is read from its element in the parsed input, never from a cut of the text. Its
xmlis that element written out: it reads back and verifies the same, but it is not the input byte for byte (a CDATA section becomes escaped text, a character reference the character, an empty element<X/>). To archive a received document, keep the bytes of the input itself, notxml. - A document signed before it went into the envelope, with other namespaces in scope, is checked where it sits and, failing that, on its own, with the SII namespace and as written. The forms differ only in namespace declarations, never in content.
- A stamp is read only when its structure leaves no doubt: one plain
TED(no prefix, no namespace declaration, no comment, CDATA or instruction) holding aDDand then anFRMT, and oneCAFwith oneDAand oneFRMAin theDD. Anything else is a failed check. - Not checked: whose certificate signed, and whether the CAF had expired when the document was stamped.
How the signature is made#
Every XML signature in the SDK has the same shape, the one the SII validates:
- Type: enveloped XMLDSig. The
Signatureelement goes inside the root, right after the signed element (</Documento><Signature>…</Signature></DTE>). - Canonicalization: Canonical XML 1.0 (
http://www.w3.org/TR/2001/REC-xml-c14n-20010315), without comments. - Digest: SHA-1 of the canonical signed element (
DigestValue, base64). - Signature algorithm: RSA with SHA-1 (
http://www.w3.org/2000/09/xmldsig#rsa-sha1) over the canonicalSignedInfo. - Transform:
enveloped-signature. - KeyInfo: the RSA key (
Modulus,Exponent) and the certificate (X509Certificate), in base64 wrapped at 64 columns.
SHA-1 is what the SII requires; it is not a choice of the SDK. The canonicalizer is Canonical XML 1.0 restricted to what SII documents use (elements, attributes, text; no comments or processing instructions), so it is not a general XMLDSig implementation.
Low-level functions#
These are the pieces signDocument, signEnvelope and the books are built from. Use them to sign another element or to inspect a signature.
| Function | What it does |
|---|---|
signEnveloped({ xml, elementTag, rootTag, certificate, includeXsiInStoredSignedInfo? }) | Signs the element elementTag (it must have an ID) and inserts the Signature between </elementTag> and </rootTag>. Returns { xml, signature }. |
signRootElement({ xml, rootTag, certificate }) | Signs the whole root element (Reference URI=""). It is the signature the SII token service verifies. Returns { xml, signature }. |
verifyEnveloped(xml, { elementTag, rootTag }) | Verifies the enveloped signature that is a direct child of the root. Returns a SignatureVerification. |
buildSignedInfo(referenceId, digestValue, options?) | Builds the SignedInfo string. options.expandTags writes empty elements as <X></X> (the form that is signed); options.includeXsi adds xmlns:xsi. |
buildSignatureXml(signedInfo, signatureValue, keyInfo) | Builds the Signature element from a SignedInfo, the (wrapped) signature and { modulus, exponent, certificate }. |
import { signEnveloped, verifyEnveloped } from 'dte-sdk'
const { xml, signature } = signEnveloped({
xml: unsignedXml, // must contain …</Documento></DTE> and an ID on Documento
elementTag: 'Documento',
rootTag: 'DTE',
certificate,
})
verifyEnveloped(xml, { elementTag: 'Documento', rootTag: 'DTE' }).valid // truesignEnveloped throws XML_PARSE_FAILED when the element, its ID attribute or the adjacent closing tags </elementTag></rootTag> are missing. signRootElement throws it when the root is not rootTag or has no closing tag. The certificate argument of both needs only privateKeyPem, publicKey and certificateBase64.
includeXsiInStoredSignedInfo controls whether the stored SignedInfo declares xmlns:xsi. The signed bytes always include it, because the canonical form inherits it from the root; the DTE stores it without (false) and envelopes and books with (true, the default). Leave it at its default unless you know the layout your verifier expects.
verifyEnveloped is what verifyDocumentSignature, verifyEnvelopeSignature, the books' verify*Signature and verifyReceivedDocuments call, each with its tags.
Crypto helpers#
Small functions over node-forge, exported for the places where you need one primitive:
| Function | What it does |
|---|---|
sha1Base64(data, encoding?) | SHA-1 of data in base64. encoding is 'utf8' (default) or 'latin1'. |
rsaSha1SignBase64(privateKeyPem, data, encoding?) | SHA1withRSA (PKCS#1 v1.5) signature in base64. Deterministic. |
rsaSha1VerifyBase64(publicKey, data, encoding, signature) | Verifies a base64 SHA1withRSA signature against a { modulus, exponent } key. Never throws: malformed input is false. |
rsaPublicKeyParts(privateKeyPem) | The { modulus, exponent } of a PEM private key (PKCS#1 or PKCS#8), as the base64 integers XMLDSig and the CAF use. |
rsaPublicKeyPartsFromCertificatePem(certificatePem) | The same, from a PEM certificate. |
parseCertificatePem(certificatePem) | Reads a PEM certificate into a ParsedCertificate: subject, commonName, notBefore, notAfter, rut, base64 and publicKey. Throws CERT_INVALID. |
certificateBase64(certificatePem) | The base64 body of a PEM certificate, without headers or whitespace. |
wrapBase64(value, width?) | Removes whitespace from a base64 string and wraps it (64 columns by default). |
The document and the XML are signed as UTF-8; the TED is signed as ISO-8859-1 (that is the 'latin1' encoding, which throws ENCODING_UNSUPPORTED_CHAR for a character above U+00FF).
Next steps#
- Envelopes: group signed documents and sign the
SetDTE. - Stamp: the step before signing.
- SII web services: the same certificate authenticates the seed and token.
- Acceptance and claims: answer the received documents you verified.
- API reference: every field of
verifyReceivedDocumentsand its checks. - Security: handling the certificate and its password.