Client certificates: mutual TLS and signed XML
Banks and government APIs often ask for two things the web fetch does not have: a client certificate in the TLS handshake (mutual TLS), and an XML document signed with the same certificate (XMLDSig). In Brazil that certificate is the company's A1, a PKCS#12 file (.pfx or .p12) with a password. Banco Inter and the national NFS-e need both.
NotusBase stores the certificate on the server and lets actions use it by name. The key and the password stay in the binary: no function can read them, log them or return them.
Store the certificate
notusbase certificate set company ./company.pfx
The password comes from the terminal, typed twice without echo, or from stdin in one line (notusbase certificate set company ./company.pfx < password.txt). It never goes on the command line. The server opens the file before storing it, so a wrong password or a broken file fails here, not in production. The answer shows the subject, the issuer, the validity and the SHA-256 fingerprint.
notusbase certificate # lists name, subject, issuer, validity and fingerprint
notusbase certificate set company ./renewed.pfx # replaces it; new requests use the new file
notusbase certificate remove company
The name is up to 64 letters, digits, _ and -. A project can hold several, for example one per company or one per bank. The commands take --url, --key and --dir like notusbase env.
Use it in an action
In the default runtime, not in a "use node" file.
import { action } from "./_generated/server";
import { v } from "convex/values";
import { fetchWithCertificate, signXml } from "notusbase-modules/certificates";
// mutual TLS: same arguments and result as fetch, plus the certificate name first.
export const bankToken = action({
args: {},
handler: async () => {
const response = await fetchWithCertificate("company", "https://api.bank.example/oauth/v2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.BANK_CLIENT_ID!,
client_secret: process.env.BANK_CLIENT_SECRET!,
}),
});
if (!response.ok) throw new Error(`bank: ${response.status} ${await response.text()}`);
return (await response.json()).access_token as string;
},
});
// XMLDSig: sign the element with Id="…", then send it.
export const sendInvoice = action({
args: { dps: v.string(), id: v.string() },
handler: async (_, { dps, id }) => {
const signed = await signXml("company", dps, { id });
const response = await fetchWithCertificate("company", "https://invoice-api.example/dps", {
method: "POST",
headers: { "content-type": "application/xml" },
body: signed,
});
return response.status;
},
});
import is what turns the module on, as with the other modules. It has no tables and no functions of its own, so there is no convex/certificates.ts to create.
fetchWithCertificate(certificate, input, init?)
Exactly fetch, with the certificate presented when the server asks for one. Everything else is the same: NOTUSBASE_FETCH_ALLOW for internal addresses, the action's time limit, streamed bodies, redirect, signal. Each certificate keeps its own connection pool.
signXml(certificate, xml, { id? })
Returns the signed document as text. The signature is what the NFS-e, the NF-e and most bank APIs expect:
| Type | enveloped, Signature without a prefix, in the http://www.w3.org/2000/09/xmldsig# namespace |
| Canonicalization | C14N 1.0, inclusive, without comments (http://www.w3.org/TR/2001/REC-xml-c14n-20010315), also as the second transform |
| Signature | RSA-SHA256, digest SHA-256 |
KeyInfo | X509Data/X509Certificate with the company's certificate (not the chain) |
With id | signs the single element whose Id, ID or id attribute has that value, with Reference URI="#id", and puts Signature right after it: <DPS><infDPS Id="…">…</infDPS><Signature>…</Signature></DPS> |
Without id | signs the whole document (URI="") and puts Signature as the last child of the root |
Zero or two elements with the id, invalid XML, or a certificate without an RSA key fail with a clear error.
Where the certificate lives
- In the
_certificatestable of the database, the file as it came (encrypted by its own password) and the password beside it. It is not a PocketBase collection: the dashboard, the records API andnotusbase exportdo not see it, and the read-only SQL console (dashboard,notusbase sqland the MCP) refuses to read it, as it refuses the other secrets. - It is not in
process.env. A function only knows the name. - It is in the database file, so it goes into the backup (local and S3) and the replica, like
_env. Whoever holds a backup holds the certificate: protect backups as you protect_env. - A copy started in isolated mode (
--isolated) does not read it, andfetchWithCertificateandsignXmlfail there with the isolated-mode error.
Limits
- Default runtime only. In a
"use node"file the module throws a clear error. A Node action that needs it calls an action without"use node". - Actions only, like
fetch.signXmlis also refused in queries and mutations. - Server trust comes from the system. The server's certificate must chain to a root the operating system trusts. A government server signed by a chain outside the system store (some ICP-Brasil chains) fails the handshake, and there is no option yet to add a root. Item 124 (NFS-e) will tell whether this is needed.
- One XML profile. No RSA-SHA1 (some older municipal NFS-e and the NF-e still use it), no exclusive C14N, no
ds:prefix, no chain inKeyInfo, no function to verify a signature. Each one is a small option when a real API asks for it. - The document is rewritten. The signed document comes out of a parser: quotes, empty tags and escapes may be written differently from the input. The canonical form, which is what the signature covers, does not change.
- Redirects. On a redirect the certificate is presented to the new host if it asks for one. Use
redirect: "error"when the API should never redirect. - No expiry alert. An A1 lasts one year.
notusbase certificateshowsnotAfter; renewing iscertificate setwith the new file. - No dashboard screen. Everything goes through the CLI.
Difference from Convex
Convex has no stored certificates. The usual way there is a "use node" action with https.Agent({ pfx, passphrase }), the file in base64 in an environment variable. That works in NotusBase too, in a Node action, but then the key is in process.env and any function can return it. notusbase-modules/certificates is the NotusBase way; code that uses it does not run on Convex.