todos los sistemas operativostodo operativo

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.10

~/blog/autenticacion-sii-certificado-digital-nodejs.md

Cómo autenticarse en el SII con certificado digital desde Node.js: semilla, token y mTLS

Guía práctica para autenticarse en los servicios del SII de Chile con un certificado digital desde Node.js: cómo leer el .pfx, firmar la semilla con XMLDSig, obtener el token, usar TLS mutuo y resolver los errores más comunes.

autor
Alejandro Peñaloza
Publicado
Lectura
5 min
Portada: Autenticarse en el SII con certificado digital, semilla, token y mTLS desde Node.js, con el ícono de un certificado y una llave
En este artículo
  1. El certificado digital: lo que realmente tienes en las manos
  2. Las dos formas de autenticarse
  3. Ambientes: certificación y producción
  4. Paso 1: leer el .pfx en Node.js
  5. Paso 2: pedir la semilla
  6. Paso 3: firmar la semilla con XMLDSig
  7. Paso 4: cambiar la semilla firmada por un token
  8. TLS mutuo: cuando el servicio pide el certificado en la conexión
  9. Errores comunes y cómo resolverlos
  10. Buenas prácticas de seguridad
  11. Conclusión

Integrarse con el SII (Servicio de Impuestos Internos de Chile) es uno de esos trabajos donde la documentación oficial explica el qué pero rara vez el cómo. Facturación electrónica, consulta de documentos, registros de compras y ventas: casi todo parte por el mismo paso, demostrarle al SII quién eres usando un certificado digital.

En este post explico el flujo completo desde Node.js, con código listo para adaptar y los errores que más tiempo me hicieron perder.

Nota: los servicios, URLs y requisitos del SII cambian con el tiempo. Usa este post como guía y valida siempre contra la documentación técnica vigente del SII antes de pasar a producción.

El certificado digital: lo que realmente tienes en las manos

Cuando compras un certificado de firma electrónica a un proveedor acreditado, normalmente lo descargas como un archivo .pfx o .p12 (formato PKCS#12) protegido con una contraseña. Ese archivo contiene:

  • Tu llave privada, con la que firmas.
  • Tu certificado X.509, con tu llave pública, tu RUT y los datos del emisor.
  • A veces, la cadena de certificados del proveedor.

Dos requisitos que se olvidan seguido:

  1. El certificado debe estar vigente.
  2. La persona dueña del certificado debe estar autorizada en el SII para actuar por la empresa (por ejemplo, como usuario administrador o con los permisos de facturación correspondientes). Un certificado válido de una persona sin permisos sobre ese RUT empresa se autentica, pero luego todo falla.

Las dos formas de autenticarse

Mecanismo Cómo funciona Dónde se usa
Semilla + token Pides una semilla, la firmas con XMLDSig y la cambias por un token que envías como cookie Web services de documentos tributarios electrónicos (DTE) y APIs que siguen el mismo patrón
TLS mutuo (mTLS) Tu cliente HTTP presenta el certificado durante el handshake TLS Servicios que piden autenticación con certificado de cliente

En ambos casos necesitas lo mismo: la llave privada y el certificado extraídos del .pfx.

Ambientes: certificación y producción

El SII separa dos ambientes, cada uno con su propio host:

Ambiente Host Uso
Certificación maullin.sii.cl Pruebas, proceso de certificación como emisor
Producción palena.sii.cl Operación real

Parametrízalo desde el inicio. Mezclar un token de certificación con un servicio de producción es un clásico.

const SII_HOST =
  process.env.SII_AMBIENTE === "produccion" ? "palena.sii.cl" : "maullin.sii.cl";

Paso 1: leer el .pfx en Node.js

Uso node-forge porque permite extraer la llave, el certificado y los datos de la llave pública que el SII pide dentro de la firma.

npm install node-forge xml-crypto fast-xml-parser
import fs from "node:fs";
import forge from "node-forge";

export function leerCertificado(rutaPfx: string, clave: string) {
  const der = fs.readFileSync(rutaPfx).toString("binary");
  const p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(der), clave);

  const llave = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
    forge.pki.oids.pkcs8ShroudedKeyBag
  ]?.[0]?.key;
  const cert = p12.getBags({ bagType: forge.pki.oids.certBag })[
    forge.pki.oids.certBag
  ]?.[0]?.cert;

  if (!llave || !cert) throw new Error("El .pfx no contiene llave o certificado");

  const ahora = new Date();
  if (ahora < cert.validity.notBefore || ahora > cert.validity.notAfter) {
    throw new Error(`Certificado fuera de vigencia (vence ${cert.validity.notAfter.toISOString()})`);
  }

  const publica = cert.publicKey as forge.pki.rsa.PublicKey;
  const aBase64 = (n: forge.jsbn.BigInteger) => {
    let hex = n.toString(16);
    if (hex.length % 2) hex = "0" + hex;
    return forge.util.encode64(forge.util.hexToBytes(hex));
  };

  return {
    llavePem: forge.pki.privateKeyToPem(llave),
    certPem: forge.pki.certificateToPem(cert),
    certBase64: forge.util.encode64(
      forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(),
    ),
    modulo: aBase64(publica.n),
    exponente: aBase64(publica.e),
  };
}

Paso 2: pedir la semilla

La semilla es un número de un solo uso que entrega el servicio CrSeed. Es un web service SOAP clásico: le envías un sobre XML y responde otro sobre con el XML de la semilla escapado dentro.

import { XMLParser } from "fast-xml-parser";

const parser = new XMLParser({ ignoreAttributes: false });

const sobreSoap = (metodo: string, cuerpo = "") => `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body><${metodo}>${cuerpo}</${metodo}></soapenv:Body>
</soapenv:Envelope>`;

async function llamarSoap(servicio: string, metodo: string, cuerpo = "") {
  const res = await fetch(`https://${SII_HOST}/DTEWS/${servicio}.jws`, {
    method: "POST",
    headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: "" },
    body: sobreSoap(metodo, cuerpo),
  });
  if (!res.ok) throw new Error(`${servicio}: HTTP ${res.status}`);

  const sobre = parser.parse(await res.text());
  const cuerpoResp = sobre["soapenv:Envelope"]["soapenv:Body"];
  const retorno = Object.values(cuerpoResp)[0] as Record<string, string>;
  const xmlInterno = Object.values(retorno).find((v) => typeof v === "string")!;
  return parser.parse(xmlInterno); // fast-xml-parser decodifica las entidades
}

export async function obtenerSemilla(): Promise<string> {
  const r = await llamarSoap("CrSeed", "getSeed");
  const resp = r["SII:RESPUESTA"];
  const estado = String(resp["SII:RESP_HDR"].ESTADO);
  if (estado !== "00") throw new Error(`Semilla rechazada, estado ${estado}`);
  return String(resp["SII:RESP_BODY"].SEMILLA);
}

Los nombres exactos de los nodos de respuesta pueden variar levemente. La primera vez, imprime la respuesta cruda con console.log y ajusta el acceso a los nodos.

Paso 3: firmar la semilla con XMLDSig

Este es el paso donde falla casi todo el mundo. El SII espera una firma XML envolvente (enveloped) sobre este documento:

<getToken><item><Semilla>012345678901</Semilla></item></getToken>

Requisitos que debes respetar:

  • Algoritmos SHA-1 (rsa-sha1 para la firma y sha1 para el digest) en este servicio. Las librerías modernas usan SHA-256 por defecto, así que hay que configurarlo explícitamente.
  • Canonicalización C14N y la transformación enveloped-signature.
  • Un KeyInfo con KeyValue (módulo y exponente RSA) y X509Data con el certificado.
  • No reformatear el XML después de firmarlo: un espacio o un salto de línea extra invalida la firma.

Con xml-crypto (la API cambió entre versiones mayores; esto corresponde a la versión 6):

import { SignedXml } from "xml-crypto";

export function firmarSemilla(semilla: string, c: ReturnType<typeof leerCertificado>) {
  const xml = `<getToken><item><Semilla>${semilla}</Semilla></item></getToken>`;

  const firma = new SignedXml({
    privateKey: c.llavePem,
    publicCert: c.certPem,
    signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
    canonicalizationAlgorithm: "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
    getKeyInfoContent: () =>
      `<KeyValue><RSAKeyValue><Modulus>${c.modulo}</Modulus>` +
      `<Exponent>${c.exponente}</Exponent></RSAKeyValue></KeyValue>` +
      `<X509Data><X509Certificate>${c.certBase64}</X509Certificate></X509Data>`,
  });

  firma.addReference({
    xpath: "/*",
    uri: "",
    isEmptyUri: true,
    transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
    digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1",
  });

  firma.computeSignature(xml);
  return firma.getSignedXml();
}

Paso 4: cambiar la semilla firmada por un token

export async function obtenerToken(c: ReturnType<typeof leerCertificado>) {
  const semilla = await obtenerSemilla();
  const firmado = firmarSemilla(semilla, c);

  const r = await llamarSoap(
    "GetTokenFromSeed",
    "getToken",
    `<pszXml><![CDATA[${firmado}]]></pszXml>`,
  );
  const resp = r["SII:RESPUESTA"];
  const estado = String(resp["SII:RESP_HDR"].ESTADO);
  if (estado !== "00") {
    throw new Error(`Token rechazado, estado ${estado}: ${resp["SII:RESP_HDR"].GLOSA ?? ""}`);
  }
  return String(resp["SII:RESP_BODY"].TOKEN);
}

El token se envía en las siguientes peticiones como cookie:

await fetch(`https://${SII_HOST}/ruta/del/servicio`, {
  headers: { Cookie: `TOKEN=${token}` },
});

El token tiene una vigencia limitada. Cachéalo (en memoria o en Redis) y renuévalo cuando expire o cuando el SII responda que no es válido; pedir uno nuevo por cada petición es lento e innecesario.

sequenceDiagram
    participant App as Tu servicio
    participant SII as SII
    App->>SII: getSeed (CrSeed)
    SII-->>App: SEMILLA
    App->>App: Firma XMLDSig con llave privada
    App->>SII: getToken(semilla firmada)
    SII-->>App: TOKEN
    App->>SII: Peticiones con Cookie TOKEN=...

TLS mutuo: cuando el servicio pide el certificado en la conexión

Algunos servicios no usan semilla: piden que el cliente presente el certificado durante el handshake TLS. En Node.js se resuelve con un https.Agent:

import https from "node:https";
import fs from "node:fs";

const agente = new https.Agent({
  pfx: fs.readFileSync(process.env.SII_PFX_PATH!),
  passphrase: process.env.SII_PFX_CLAVE,
  keepAlive: true,
});

https.get("https://servicio.que.pide.certificado/", { agent: agente }, (res) => {
  console.log(res.statusCode, res.headers["set-cookie"]);
});

Si usas fetch nativo (basado en undici), el equivalente es un Agent de undici con connect: { pfx, passphrase } pasado en la opción dispatcher. Muchos de estos flujos devuelven cookies de sesión que luego debes reenviar, así que conviene un cliente con manejo de cookies.

Errores comunes y cómo resolverlos

"Unsupported PKCS12 PFX data" o "unsupported" al leer el .pfx

Desde Node.js 17, que usa OpenSSL 3, los .pfx cifrados con algoritmos antiguos (como RC2-40, muy común en certificados emitidos hace años) se rechazan. La solución es reempaquetarlo con algoritmos modernos:

openssl pkcs12 -in certificado.pfx -nodes -legacy -out temporal.pem
openssl pkcs12 -export -in temporal.pem -out certificado-moderno.pfx
shred -u temporal.pem   # el PEM intermedio tiene la llave sin cifrar

El token se rechaza aunque la firma "se ve bien"

  • Revisa que usaste SHA-1 y no SHA-256.
  • Verifica que no estés formateando el XML después de firmar (un prettyPrint lo rompe).
  • Confirma que el KeyInfo trae el módulo, el exponente y el certificado.
  • Valida la firma localmente con xmlsec1 --verify antes de culpar al SII.

Todo funciona en certificación y falla en producción

Casi siempre es el host (maullin vs palena) o que la persona del certificado no tiene los permisos sobre la empresa en producción.

Errores intermitentes

El SII tiene ventanas de mantención y momentos de alta carga (cierres de mes, declaraciones). Implementa reintentos con espera exponencial y registra cada respuesta con su código de estado para poder diagnosticar.

Buenas prácticas de seguridad

  • Nunca subas el .pfx al repositorio. Guárdalo en un gestor de secretos o, como mínimo, fuera del código con permisos 600.
  • La contraseña del certificado va en una variable de entorno o gestor de secretos, nunca en el código.
  • Centraliza la autenticación en un solo servicio interno que maneje el certificado y entregue tokens; así el certificado vive en un solo lugar.
  • Registra cuándo vence el certificado y crea una alerta con semanas de anticipación. Un certificado vencido detiene la facturación completa.
  • No registres en los logs ni la semilla firmada ni el token completo.

Conclusión

Autenticarse en el SII no es difícil, pero es implacable con los detalles: el algoritmo de hash, la forma del KeyInfo, un espacio de más o un .pfx con cifrado antiguo bastan para perder una tarde. Con el flujo semilla, firma y token bien encapsulado en un servicio, y el certificado tratado como el secreto crítico que es, el resto de la integración se vuelve mucho más llevadero.

compartir:linkedinxblueskywhatsappreddithacker newsfacebookcorreo

Comentarios

Aún no hay comentarios. ¿Quieres dejar el primero?

Deja un comentario

Los comentarios se revisan antes de publicarse. No pido correo ni guardo tu IP.

$ ls ~/blog | grep relacionados

Sigue leyendo