Firma
El SII valida cada documento y cada sobre con una firma XML envuelta (XMLDSig) hecha con el certificado digital de quien firma. loadCertificateFromPkcs12 y loadCertificateFromPem convierten tu certificado en un objeto Certificate de datos planos, signDocument firma con él un documento timbrado, y verifyDocumentSignature comprueba un <DTE> firmado. verifyReceivedDocuments revisa lo que envía un proveedor: la firma, el timbre y el CAF de cada documento. Debajo hay un puñado de funciones de bajo nivel que construyen y verifican la firma de cualquier elemento, y que reutilizan los sobres, los libros y el cliente del SII.
Todo lo de esta página vive en la entrada dte-sdk y corre en Node, Bun y Deno. Lee PKCS#12 y firma con node-forge, una de las cuatro dependencias de ejecución del SDK.
Un certificado contiene una llave privada. Mantén el archivo .pfx y su
contraseña fuera del repositorio y fuera de los logs; lee la contraseña desde
el entorno (process.env.PFX_PASSWORD!). Un Certificate cargado son datos
planos y guarda la llave privada en privateKeyPem: nunca lo serialices
(JSON.stringify) hacia un log o una respuesta.
Las firmas están probadas contra datos sintéticos y verifican con el verificador de este mismo SDK, pero ningún documento firmado se ha enviado todavía al SII real. Mira Estado.
Uso#
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', el RUT del titular leído desde el certificado
certificate.notAfter // Date: cuándo vence
const caf = parseCaf(readFileSync('caf-33.xml', 'utf8'))
const stamped = stampDocument(createDocument(input), caf)
const signed = signDocument(stamped, certificate)
signed.xml // el <DTE> firmado, con su TED y su Signature, sin declaración XML
signed.signedAt // '2026-11-15T15:30:00', hora de Chile (TmstFirma)
signed.signature // { digestValue, signatureValue }
verifyDocumentSignature(signed.xml)
// { referenceId: 'DTE_T33F7', digestValid: true, signatureValid: true, valid: true }Cargar un certificado#
Desde un archivo PKCS#12#
loadCertificateFromPkcs12(bytes, password, options?) lee un archivo .pfx o .p12. bytes es un Uint8Array (un Buffer de Node lo es).
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)Lee las dos generaciones del formato: archivos legacy (3DES y RC2) y modernos (PBES2 con AES), con bolsas de llave cifradas o planas. Encuentra el certificado hoja comparando su clave pública con la llave privada, ordena la cadena desde la hoja hasta la raíz (los endpoints TLS del SII necesitan los intermedios) y lee el RUT del titular desde el certificado.
Desde PEM#
loadCertificateFromPem({ privateKeyPem, certificatePem, chainPem? }, options?) construye el mismo objeto desde strings, por ejemplo después de convertir un .pfx con openssl pkcs12 -nodes:
| Campo | Significado |
|---|---|
privateKeyPem | La llave privada, PKCS#1 (RSA PRIVATE KEY) o PKCS#8 (PRIVATE KEY), sin cifrar. |
certificatePem | El certificado hoja. |
chainPem | La cadena completa para TLS, con la hoja primero. Por defecto, el certificado hoja. |
Comprueba que la llave privada corresponda al certificado (CERT_INVALID cuando no) y que el certificado esté vigente ahora: que no haya pasado su notAfter (CERT_EXPIRED) ni sea anterior a su notBefore (CERT_NOT_YET_VALID). loadCertificateFromPkcs12 hace las mismas dos comprobaciones.
Opciones#
Ambos cargadores reciben LoadCertificateOptions:
| Opción | Significado |
|---|---|
now | Reloj para los chequeos de vigencia (tests). |
allowExpired | Cargar un certificado fuera de su vigencia en vez de lanzar CERT_EXPIRED (vencido) o CERT_NOT_YET_VALID (aún no vigente). Para verificar datos antiguos. |
El tipo Certificate#
| Campo | Significado |
|---|---|
privateKeyPem | La llave privada en PEM. Secreto. |
certificatePem | El certificado hoja en PEM (se usa para firmar XML). |
chainPem | Hoja, intermedios y raíz en PEM (autenticación de cliente TLS). Igual a certificatePem cuando no hay cadena. |
certificateBase64 | La hoja en DER, base64 (el X509Certificate de la firma). |
publicKey | { modulus, exponent } en base64 (el RSAKeyValue de la firma). |
rut | El RUT del titular en forma SII (12345678-5), o null cuando el certificado no trae ninguno. |
commonName | El nombre común (CN), o null. |
subject | Los campos del sujeto por nombre corto (CN, O, OU, serialNumber, E, C…). |
notBefore | Date en que comienza la vigencia. |
notAfter | Date en que termina la vigencia. |
El RUT se lee desde el certificado, en este orden: el otherName del SubjectAltName con OID 1.3.6.1.4.1.8321.1 (el que define la ley chilena de firma electrónica), luego el serialNumber del sujeto, luego la OU, y luego un token con forma de RUT en el CN. Cada candidato debe pasar rut.ts; uno inválido cae al siguiente. Ese es el RUT que usas como senderRut en la carátula de un sobre, porque el SII espera que quien envía sea el titular del certificado que firma. Mira Sobres.
Vencimiento#
import { daysUntilExpiry, isCertificateExpired } from 'dte-sdk'
daysUntilExpiry(certificate) // 412, días enteros; negativo una vez vencido
isCertificateExpired(certificate) // false
isCertificateExpired(certificate, { marginDays: 30 }) // true cuando vence dentro de 30 díasisCertificateExpired(certificate, { now?, marginDays? }) es true cuando now pasó notAfter menos marginDays (por defecto 0). daysUntilExpiry(certificate, { now? }) es floor((notAfter - now) / un día). Revisa el margen al arrancar, para que un certificado que vence la próxima semana se note antes de romper una corrida de emisión.
Errores#
| Código | Cuándo |
|---|---|
CERT_PASSWORD_WRONG | La contraseña del PKCS#12 es incorrecta. Ningún texto de la contraseña aparece en el error. |
CERT_INVALID | El archivo no es un PKCS#12, no tiene llave privada o certificado, el PEM no se puede leer, o la llave no corresponde al certificado. details.reason dice cuál. |
CERT_EXPIRED | El certificado pasó su notAfter (details.expiresAt). Pasa allowExpired: true para cargarlo igualmente. |
CERT_NOT_YET_VALID | El notBefore del certificado todavía no llega respecto de now (details.validFrom), por ejemplo uno emitido para una fecha posterior. Pasa allowExpired: true para cargarlo igualmente. |
import { isDteError } from 'dte-sdk'
try {
loadCertificateFromPkcs12(bytes, process.env.PFX_PASSWORD!)
} catch (error) {
if (isDteError(error) && error.code === 'CERT_PASSWORD_WRONG') {
// pide la contraseña de nuevo, no reintentes en un ciclo
}
}Firmar un documento#
signDocument(stampedDocument, certificate, options?) renderiza el XML del documento con su TED, firma el elemento Documento (el elemento Liquidacion en una liquidación factura, 43) y devuelve un SignedDocument: el documento timbrado más:
| Campo | Significado |
|---|---|
xml | El XML <DTE> firmado. No lleva declaración XML (la agrega el sobre). |
signedAt | TmstFirma, hora de Chile, escrita dentro del elemento firmado. |
signature | { digestValue, signatureValue }: el digest SHA-1 en base64 y la firma RSA en base64, en una línea. |
| Opción | Significado |
|---|---|
now | Reloj para TmstFirma. |
signedAt | TmstFirma exactamente como se entrega, en vez de la hora de Chile de now. |
La entrada debe ser un StampedDocument (mira Timbre); eso se comprueba al compilar, así no puedes firmar por error un documento sin TED. La firma es determinista: el mismo documento, certificado y horas dan los mismos bytes.
El documento firmado es lo que entra en un sobre (Sobres); un <DTE> firmado solo no se envía al SII.
Verificar una firma#
verifyDocumentSignature(xml) verifica la firma de un XML <DTE>, propio o recibido, y devuelve una SignatureVerification:
| Campo | Significado |
|---|---|
referenceId | El ID al que apunta la firma (DTE_T33F7), o null cuando SignedInfo no tiene referencia, o tiene más de una. |
digestValid | SignedInfo tiene una sola Reference, con SHA-1 y sin transformadas salvo enveloped-signature o C14N 1.0, y el elemento al que apunta es único, es hijo directo del raíz junto a Signature, tiene la etiqueta esperada y sigue produciendo el DigestValue. |
signatureValid | SignedInfo declara C14N 1.0 y RSA-SHA1, y SignatureValue verifica sobre él con el certificado embebido en KeyInfo. |
valid | Pasaron ambos chequeos. |
verifyDocumentSignature(signed.xml).valid // true
verifyDocumentSignature(signed.xml.replace('<MntTotal>119000</MntTotal>', '<MntTotal>1</MntTotal>')).valid // falseUn elemento alterado, una referencia ausente, un ID duplicado o un elemento movido a otro lugar del árbol da digestValid: false; no lanza, así que un elemento envuelto o duplicado nunca verifica. Lanza XML_PARSE_FAILED cuando el XML está mal formado, anidado a más de 100 niveles (el límite de todo DOM que parsea el SDK) o el raíz no es DTE, y SIGNATURE_NOT_FOUND cuando no hay Signature bajo el raíz o no hay SignedInfo dentro de ella.
Solo verifica una firma de la forma que hace el SDK: una Reference con el único DigestValue, SHA-1, sin transformadas salvo enveloped-signature o C14N 1.0, y C14N 1.0 con RSA-SHA1 en SignedInfo. Una segunda Reference, otro digest u otra transformada da digestValid: false; otra canonicalización (C14N exclusiva, por ejemplo) u otro método de firma da signatureValid: false. Antes de la 0.4.0 el verificador revisaba solo la primera Reference y no leía los algoritmos. Las firmas que hace el SDK pasan igual que antes.
valid prueba que el elemento y SignedInfo están íntegros respecto del certificado que viaja en el mismo XML. No prueba que ese certificado pertenezca al firmante que esperas, ni que sea válido ante una entidad certificadora: eso es otro chequeo, y el SII hace el suyo. Verifica el timbre por separado con verifyTed (Timbre), o revisa un documento recibido completo con verifyReceivedDocuments (más abajo).
La función necesita el XML cuyo raíz es <DTE>: para un documento dentro de un sobre, usa el xml de cada documento firmado, tal como está en envelope.documents.
Verificar documentos recibidos#
verifyReceivedDocuments(xml, options?) revisa lo que te envió un proveedor, tal como llegó: un DTE, un EnvioDTE o un EnvioBOLETA. Revisa la firma del propio sobre y, por cada documento, su firma, su timbre y el CAF que trae el timbre, y devuelve cada chequeo como datos.
Todavía no se ejecuta sobre un documento que haya enviado un proveedor. Las pruebas firman CAF de prueba con un par de llaves del SII de prueba. Las dos llaves del SII se revisaron una vez a mano, fuera de las pruebas: la llave 100 verifica el CAF del ejemplo que publica el SII, y la 300 verificó los CAF de producción que traían cuatro documentos reales. Revisa Estado.
import { readFileSync } from 'node:fs'
import { decodeLatin1, isClaimDocumentType, verifyReceivedDocuments } from 'dte-sdk'
// Decodifica los bytes con la codificación que nombra la declaración XML: ISO-8859-1 en los documentos del SII.
// Producción por defecto: un documento timbrado con un CAF de certificación vuelve como inválido.
const { envelope, documents, status } = verifyReceivedDocuments(decodeLatin1(readFileSync('EnvioDTE.xml')))
status // 'valid' | 'invalid' | 'unchecked': el peor entre el sobre y sus documentos
envelope?.valid // la firma del propio sobre; envelope es null para un DTE solo
documents[0] // { type: 33, folio: 1234, issuerRut: '10000004-0', totalAmount: 119000, signature, ted, caf, status, xml, … }
// Los documentos que puedes aceptar o reclamar en el registro del SII
const toAnswer = documents.filter((doc) => doc.status === 'valid' && isClaimDocumentType(doc.type))Cada documento se revisa de tres formas:
| Chequeo | Qué debe cumplirse |
|---|---|
signature | Su firma XMLDSig sobre Documento (o Liquidacion), con found, digestValid y signatureValid. Igual que con verifyDocumentSignature, prueba que el documento está íntegro respecto del certificado que trae, no de quién es ese certificado. |
ted | El timbre. Su FRMT verifica con la llave del CAF que trae el TED, sobre el DD en bytes ISO-8859-1, y su DD coincide con el encabezado: mismatches nombra lo que difiere entre el emisor, el tipo, el folio, la fecha, el receptor y el total. RSR e IT1 son texto libre que cada emisor pliega a su manera, y no se comparan. |
caf | El CAF del timbre, contra el documento (mismatches: otro emisor, otro tipo, un folio fuera de su rango) y contra el SII: siiSignature revisa su FRMA, la firma del SII sobre el DA, con la llave del SII que nombra su IDK. |
El status de un documento es invalid si falla un chequeo, unchecked si ninguno falla pero el IDK del CAF no tiene una llave conocida, y valid en otro caso; el status del resultado completo es el peor entre el sobre y los documentos. Un documento que falla un chequeo es un resultado, no un error. La función lanza solo con una entrada que no puede leer: XML_PARSE_FAILED para XML mal formado, un DOCTYPE, otro raíz, un sobre sin exactamente un SetDTE, un DTE o Documento con prefijo, un DTE sin su Documento, o elementos anidados a más de 100 niveles, y ENVELOPE_EMPTY para un sobre sin documentos. Antes de la 0.7.0 un archivo de unos miles de elementos anidados desbordaba la pila con un RangeError; ningún documento que escriben el SII o un emisor se acerca a 100.
Las llaves del SII y el ambiente#
SII_CAF_KEYS tiene las llaves 100 (certificación) y 300 (producción) con que el SII firma cada CAF, tomadas de los certificados del SII tal como los publican CryptoSys y LibreDTE, los mismos bytes en ambos. Solo se acepta la llave de environment, production por defecto: un CAF firmado con la otra es invalid, con la diferencia environment, y caf.environment dice cuál es, así que un documento de certificación nunca pasa por uno de producción. Pasa environment: 'certification' para revisar documentos del ambiente de certificación. siiKeys reemplaza las llaves; expande SII_CAF_KEYS en él para agregar una. cafEnvironment(keyId) dice para qué ambiente es un IDK: certification para 100, production para 300, null para otro.
verifyCafSignature(cafXml, { environment }) revisa la FRMA de un solo CAF con la misma regla y devuelve 'valid', 'invalid' o 'unchecked'. Para revisar el CAF que llevarán tus propios TED, pásale el bloque que guarda parseCaf:
import { readFileSync } from 'node:fs'
import { parseCaf, verifyCafSignature } from 'dte-sdk'
const caf = parseCaf(readFileSync('caf-33.xml', 'latin1')) // los archivos CAF son ISO-8859-1
verifyCafSignature(caf.xml, { environment: 'certification' }) // 'valid' para un CAF que el SII emitió en certificaciónLanza CAF_INVALID cuando el texto no es XML, trae un DOCTYPE o no tiene un solo CAF con un DA y una FRMA. Una FRMA que está pero vacía es una firma que falla, no un CAF que no lo es: es invalid, sea cual sea el IDK del CAF, como una FRMA que no es base64. Antes de la 0.7.0 lanzaba CAF_INVALID, y en verifyReceivedDocuments un CAF así no era CAF, así que el timbre, sin su llave, también fallaba. Ahora el CAF se encuentra y falla su firma del SII, y el timbre se chequea con la llave del CAF como cualquier otro.
Qué se revisa y qué no#
- Lo que se revisa es lo que se informa. Cada documento se lee desde su elemento en la entrada parseada, nunca de un trozo del texto. Su
xmles ese elemento escrito: se lee y verifica igual, pero no es la entrada byte a byte (una sección CDATA pasa a texto escapado, una referencia de carácter al carácter, un elemento vacío a<X/>). Para archivar un documento recibido, guarda los bytes de la propia entrada, noxml. - Un documento firmado antes de entrar al sobre, con otros namespaces en alcance, se revisa donde está y, si eso falla, solo, con el namespace del SII y tal como viene. Las formas difieren solo en las declaraciones de namespace, nunca en el contenido.
- Un timbre se lee solo cuando su estructura no deja dudas: un
TEDsimple (sin prefijo, sin declaración de namespace, sin comentarios, CDATA ni instrucciones) con unDDy luego unaFRMT, y unCAFcon unDAy unaFRMAen elDD. Cualquier otra cosa es un chequeo fallido. - No se revisa: de quién es el certificado que firmó, ni si el CAF estaba vencido cuando se timbró el documento.
Cómo se hace la firma#
Toda firma XML del SDK tiene la misma forma, la que valida el SII:
- Tipo: XMLDSig envuelta. El elemento
Signatureva dentro del raíz, justo después del elemento firmado (</Documento><Signature>…</Signature></DTE>). - Canonicalización: Canonical XML 1.0 (
http://www.w3.org/TR/2001/REC-xml-c14n-20010315), sin comentarios. - Digest: SHA-1 del elemento firmado canónico (
DigestValue, base64). - Algoritmo de firma: RSA con SHA-1 (
http://www.w3.org/2000/09/xmldsig#rsa-sha1) sobre elSignedInfocanónico. - Transformación:
enveloped-signature. - KeyInfo: la clave RSA (
Modulus,Exponent) y el certificado (X509Certificate), en base64 cortado a 64 columnas.
SHA-1 es lo que exige el SII; no es una elección del SDK. El canonicalizador es Canonical XML 1.0 acotado a lo que usan los documentos del SII (elementos, atributos, texto; sin comentarios ni instrucciones de procesamiento), así que no es una implementación general de XMLDSig.
Funciones de bajo nivel#
Son las piezas con que se construyen signDocument, signEnvelope y los libros. Úsalas para firmar otro elemento o para inspeccionar una firma.
| Función | Qué hace |
|---|---|
signEnveloped({ xml, elementTag, rootTag, certificate, includeXsiInStoredSignedInfo? }) | Firma el elemento elementTag (debe tener un ID) e inserta la Signature entre </elementTag> y </rootTag>. Devuelve { xml, signature }. |
signRootElement({ xml, rootTag, certificate }) | Firma el elemento raíz completo (Reference URI=""). Es la firma que verifica el servicio de token del SII. Devuelve { xml, signature }. |
verifyEnveloped(xml, { elementTag, rootTag }) | Verifica la firma envuelta que es hija directa del raíz. Devuelve una SignatureVerification. |
buildSignedInfo(referenceId, digestValue, options?) | Construye el string SignedInfo. options.expandTags escribe los elementos vacíos como <X></X> (la forma que se firma); options.includeXsi agrega xmlns:xsi. |
buildSignatureXml(signedInfo, signatureValue, keyInfo) | Construye el elemento Signature desde un SignedInfo, la firma (cortada en columnas) y { modulus, exponent, certificate }. |
import { signEnveloped, verifyEnveloped } from 'dte-sdk'
const { xml, signature } = signEnveloped({
xml: unsignedXml, // debe contener …</Documento></DTE> y un ID en Documento
elementTag: 'Documento',
rootTag: 'DTE',
certificate,
})
verifyEnveloped(xml, { elementTag: 'Documento', rootTag: 'DTE' }).valid // truesignEnveloped lanza XML_PARSE_FAILED cuando faltan el elemento, su atributo ID o las etiquetas de cierre adyacentes </elementTag></rootTag>. signRootElement lo lanza cuando el raíz no es rootTag o no tiene etiqueta de cierre. El argumento certificate de ambas solo necesita privateKeyPem, publicKey y certificateBase64.
includeXsiInStoredSignedInfo controla si el SignedInfo guardado declara xmlns:xsi. Los bytes firmados siempre lo incluyen, porque la forma canónica lo hereda del raíz; el DTE lo guarda sin él (false) y los sobres y libros con él (true, el valor por defecto). Déjalo en su valor por defecto salvo que conozcas el formato que espera tu verificador.
verifyEnveloped es lo que llaman verifyDocumentSignature, verifyEnvelopeSignature, los verify*Signature de los libros y verifyReceivedDocuments, cada una con sus etiquetas.
Helpers criptográficos#
Funciones pequeñas sobre node-forge, exportadas para los lugares donde necesitas una sola primitiva:
| Función | Qué hace |
|---|---|
sha1Base64(data, encoding?) | SHA-1 de data en base64. encoding es 'utf8' (por defecto) o 'latin1'. |
rsaSha1SignBase64(privateKeyPem, data, encoding?) | Firma SHA1withRSA (PKCS#1 v1.5) en base64. Determinista. |
rsaSha1VerifyBase64(publicKey, data, encoding, signature) | Verifica una firma SHA1withRSA en base64 contra una clave { modulus, exponent }. Nunca lanza: una entrada mal formada da false. |
rsaPublicKeyParts(privateKeyPem) | El { modulus, exponent } de una llave privada PEM (PKCS#1 o PKCS#8), como los enteros base64 que usan XMLDSig y el CAF. |
rsaPublicKeyPartsFromCertificatePem(certificatePem) | Lo mismo, desde un certificado PEM. |
parseCertificatePem(certificatePem) | Lee un certificado PEM a un ParsedCertificate: subject, commonName, notBefore, notAfter, rut, base64 y publicKey. Lanza CERT_INVALID. |
certificateBase64(certificatePem) | El cuerpo base64 de un certificado PEM, sin cabeceras ni espacios. |
wrapBase64(value, width?) | Quita los espacios de un base64 y lo corta en columnas (64 por defecto). |
El documento y el XML se firman como UTF-8; el TED se firma como ISO-8859-1 (es la codificación 'latin1', que lanza ENCODING_UNSUPPORTED_CHAR para un carácter sobre U+00FF).
Siguientes pasos#
- Sobres: agrupa documentos firmados y firma el
SetDTE. - Timbre: el paso previo a firmar.
- Servicios web del SII: el mismo certificado autentica la semilla y el token.
- Aceptación y reclamos: responde los documentos recibidos que verificaste.
- Referencia de la API: cada campo de
verifyReceivedDocumentsy sus chequeos. - Seguridad: cómo manejar el certificado y su contraseña.