NotusBase

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:

Typeenveloped, Signature without a prefix, in the http://www.w3.org/2000/09/xmldsig# namespace
CanonicalizationC14N 1.0, inclusive, without comments (http://www.w3.org/TR/2001/REC-xml-c14n-20010315), also as the second transform
SignatureRSA-SHA256, digest SHA-256
KeyInfoX509Data/X509Certificate with the company's certificate (not the chain)
With idsigns 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 idsigns 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

Limits

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.