Security
Issuing DTEs means holding two kinds of secrets, the CAF private keys and your digital certificate, and processing data that identifies businesses and people. This page covers what dte-sdk does to protect them and how to use it defensively.
Found a vulnerability? Please report it privately through the security
policy (SECURITY.md) of the dte-sdk repository,
not in a public issue.
What you're protecting#
| Asset | Why it matters |
|---|---|
| CAF private key | Anyone who has it can stamp documents with your folios |
Certificate (.pfx/.p12) and its password | Anyone who has both can sign documents and talk to the SII as your authorized signer |
| Document data | RUTs, business names, addresses, emails and amounts of your customers and your company |
Handling CAF files#
A CAF contains the private key that stamps every document in its range, so it deserves the same care as a password.
- Store CAFs as secrets: in a secrets manager or an encrypted store, never in your repository, a public bucket or a container image.
- Load them on the server, when you stamp.
stampDte()accepts the raw CAF XML, so you don't need to persist a parsed copy anywhere else. - Track used folios transactionally, so two workers can't stamp the same folio with the same key.
- Never send a CAF to the browser. Nothing about building or previewing a document needs it.
import { parseCaf, stampDte } from 'dte-sdk'
// `secrets` stands for your secrets manager: not the repo, not the client.
const caf = parseCaf(await secrets.get('dte/caf/33'))
const stamped = stampDte(invoice, caf)Handling certificates and passwords#
- Keep the file and the password apart, for example the
.pfxin a secrets store and the password in an environment variable, so one leak doesn't hand over both. - Load once, reuse the handle.
loadCertificate()returns an opaqueCertificate; reusing it also letsdte-sdkreuse the cached SII token. - The password stays private.
loadCertificate()never logs it, never stores it on the result and never includes it in an error. - Plan for expiry. Certificates expire; track the renewal date so signing doesn't stop on a Friday afternoon.
import { readFile } from 'node:fs/promises'
import { loadCertificate } from 'dte-sdk'
const certificate = await loadCertificate(
await readFile(process.env.DTE_CERT_PATH!),
process.env.DTE_CERT_PASSWORD!,
)Never commit a CAF, a .pfx file or a certificate password, not even to a
private repository. If one leaks, treat it as compromised: revoke the
certificate with your provider and stop using the leaked CAF.
Sign on the server#
signDte() runs on Web Crypto, so it can run in the browser. That doesn't mean it should: signing in the browser means shipping the private key to the browser. Split the work by what each function needs:
| Function | Needs a secret | Where to run it |
|---|---|---|
createDte(), calculateTotals(), toXml() | No | Anywhere, including the browser |
parseCaf(), stampDte() | CAF private key | Server |
loadCertificate(), signDte() | Certificate and password | Server |
sendToSii(), getStatus() | Certificate | Server |
Build and preview documents on the client if you like, then send the validated input to your server to stamp, sign and submit. The exception is a tool where a person signs with their own certificate on their own device and nothing leaves it: there, browser signing keeps the key where it already lives.
Generic error messages#
Every DteError message is generic. It never includes RUTs, amounts, CAF keys or certificate passwords, so nothing sensitive reaches your logs, traces or error tracker through an exception. Branch on error.code, never on the message text.
import { createDte, DteError } from 'dte-sdk'
try {
createDte(input)
} catch (error) {
if (error instanceof DteError) {
console.error(error.code) // 'INVALID_RUT'
console.error(error.message) // generic: the RUT itself is never included
}
}Keep personal data out of logs#
A document carries personal and commercial data: RUTs, names, addresses, emails and amounts. Chile's personal data protection law applies to it, and logs are where it usually leaks.
- Don't log inputs, documents or XML. A
Dte, aSignedDteor the output oftoXml()contains everything above. - Log identifiers that aren't personal: document type, folio, track id and error code are enough to trace a document.
- Don't add the value back. Keep your own log lines around a failed call as generic as the error itself.
logger.info('DTE sent', {
type: signed.type,
folio: signed.folio,
trackId,
})
// Not: logger.info('DTE sent', signed). That logs RUTs, names and amounts.Defensive checklist#
Keep secrets on the server
CAFs, certificates and passwords live in a secrets store and are loaded only by the code that stamps, signs and sends.
Choose the environment explicitly
Read environment from configuration, never infer it. Run every new
document type through 'certification' before 'production'.
Branch on codes, not messages
Handle DteError by code. Retry SII_UNAVAILABLE; fix the cause of
everything else.
Bound network calls
Pass an AbortSignal such as AbortSignal.timeout(30_000) to
sendToSii() so a slow SII can't hold a worker forever.
Log identifiers, not documents
Type, folio, track id and error code. Never RUTs, names, amounts or XML.
Supply chain#
dte-sdk has zero runtime dependencies, so the code you audit is the code you run: about 8.4 KB min+gzip for the whole bundle. The test suite has 412 tests in 14 suites, all passing. Pin the version in your lockfile and review the changelog before upgrading, as you would for any package that touches signing keys.
Reporting a vulnerability#
Please don't open a public issue for a security problem. Use GitHub's private vulnerability reporting on the dte-sdk repository, as described in its SECURITY.md, and include:
- a description of the issue and its impact,
- a minimal reproduction (function, input and observed vs. expected result),
- the affected version.
Never include a real CAF, certificate, password or customer data in a report. Fixtures or redacted values are enough.