HP LaserJet Enterprise printers ship with a self-signed certificate and no ACME client. Mine now serves a Let’s Encrypt certificate on its admin page and on IPPS/AirPrint, and a small Alpine container on Proxmox renews it every two months without me touching the printer. Here is how.

The short version:

  • One setting in the printer’s web interface, Use for Network Identity, covers both HTTPS on 443 and ipps://…/ipp/print.
  • The certificate is issued elsewhere with a DNS-01 challenge. I run certbot in an LXC container on Proxmox.
  • The key must be RSA. The firmware rejects EC private keys outright.
  • Upload is a password-protected .pfx built with openssl pkcs12 -legacy.
  • The upload form returns HTTP 500 to scripted POSTs, so the renewal hook drives the web interface with headless Chromium instead. About 30 seconds per renewal.
  • The printer sends only the leaf certificate, never the intermediate. Apple clients, Chrome and Firefox fetch it themselves. OpenSSL, Linux curl, Python and Go do not.

Hardware: HP Color LaserJet M553 (FutureSmart 3), firmware 2309118_002275, datecode 20250112. Other FutureSmart models should behave the same.

Why bother

macOS pins a printer’s self-signed certificate. Every time the printer is reset and generates a new one, printing breaks until you delete the stale certificate from Keychain and re-add the printer. The default certificate also has the printer’s mDNS name as CN, no subjectAltName, and a five-year lifetime. A real certificate with the printer’s real hostname fixes all of that.

Where certificates live on the printer

https://<printer>/hp/device/CertificatesTabs/Index, or Security → Certificate Management in the menus. It offers a self-signed regenerate, a CSR flow, a .pfx import, a CA store, and Use for Network Identity.

I went with the .pfx import. The CSR flow keeps the key on the device, which is nicer in principle, but Let’s Encrypt renews every 60 days and each renewal would mean pulling a fresh CSR out of the printer by hand.

The CA store is what the printer trusts when it talks to SMTP or LDAP. It does nothing for clients trusting the printer, so skip it. Do set an admin password under Security → General Security; there is none out of the box.

The Proxmox container

Certbot needs an always-on host that can reach the printer, so I use Proxmox.

On the node:

pveam update
pveam download local alpine-3.24-default_20260301_amd64.tar.xz   # pick whatever `pveam available` lists
pct create $(pvesh get /cluster/nextid) local:vztmpl/alpine-3.24-default_20260301_amd64.tar.xz \
  --hostname printer-certs --ostype alpine --unprivileged 1 \
  --cores 2 --memory 1024 --swap 0 --rootfs local-lvm:8 \
  --net0 name=eth0,bridge=vmbr0,ip=dhcp \
  --nameserver "<internal DNS 1> <internal DNS 2>" --searchdomain example.internal \
  --onboot 1 --start 1

Inside (pct enter <id>):

apk add chromium nodejs npm certbot certbot-dns-rfc2136 openssl font-noto
mkdir -p /usr/local/lib/printer-cert && cd /usr/local/lib/printer-cert
npm install --no-audit --no-fund puppeteer-core     # no bundled Chrome; use the apk one

Chromium, Node and certbot take about 600 MB of the 8 GB disk.

Two Alpine things to fix before anything will resolve the printer’s internal name:

  • udhcpc overwrites /etc/resolv.conf with the DHCP server’s resolvers, ignoring the --nameserver you gave pct create. Set RESOLV_CONF="no" in /etc/udhcpc/udhcpc.conf and write /etc/resolv.conf by hand. Debian and Ubuntu containers need the equivalent dhclient enter-hook.
  • musl queries every nameserver in parallel and takes the first answer. If any one of your internal DNS servers lacks the printer’s record, lookups fail intermittently. Add the record everywhere and run getent hosts twenty times in a row before trusting it.

Issue the certificate

The printer cannot answer HTTP-01 from the internet, so this is DNS-01. My zone takes RFC 2136 dynamic updates with a TSIG key; any certbot DNS plugin will do.

/etc/letsencrypt/rfc2136.ini, mode 600:

dns_rfc2136_server = <IP address of the update server, not a hostname>
dns_rfc2136_port = 53
dns_rfc2136_name = <TSIG key name>
dns_rfc2136_secret = <TSIG secret>
dns_rfc2136_algorithm = HMAC-SHA256
certbot certonly --non-interactive --agree-tos --keep-until-expiring \
  --dns-rfc2136 \
  --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini \
  --dns-rfc2136-propagation-seconds 30 \
  --key-type rsa --rsa-key-size 2048 \
  -d printer.example.internal

The hostname never has to resolve publicly. DNS-01 only needs the _acme-challenge TXT record in the public zone; the A record can live in split-horizon internal DNS.

Why RSA

Certbot has defaulted to ECDSA since 2.0, and --key-type rsa is not optional here. Probed with openssl s_client, the printer does TLS 1.2 only, no 1.3, and the only ECDHE group it accepts is P-256. So it can do ECDH, but whether it can hold an EC key is a separate question. I built throwaway self-signed certificates under a different name and tried importing each, then deleted it again:

PFX Key Encoding Verdict
control RSA 2048 legacy accepted
P-256 EC legacy “Certificate file not valid”
P-256 EC AES-256 / PBKDF2 “Certificate file not valid”
P-384 EC legacy “Certificate file not valid”

The RSA control rules out the self-signed shape and the PKCS#12 encoding as the cause. The web interface says the same in its own way: its CSR and self-signed forms offer “RSA Key Length: 1024 bits / 2048 bits” and nothing else. This firmware cannot generate or import an EC key.

Build the PFX

The printer accepts only .pfx for an identity certificate with a private key. OpenSSL 3 writes AES-256/PBKDF2 PKCS#12 by default, which embedded firmware often cannot read. -legacy restores 3DES/RC2 with a SHA-1 MAC, which this firmware imported without complaint:

openssl rand -base64 18 > pfx.pass
openssl pkcs12 -export -legacy \
  -inkey privkey.pem -in cert.pem -certfile chain.pem \
  -name printer.example.internal \
  -passout file:pfx.pass -out printer.pfx

Install it by hand, once

On the certificates page: Import Identity Certificate with Private Key, pick the .pfx, enter the password, Install. Then select the new entry and click Use for Network Identity. The web server restarts. This one setting is what both the admin UI on 443 and ipps://…:443/ipp/print use, so AirPrint and the web interface change together.

Verify from a client:

$ openssl s_client -connect printer.example.internal:443 -showcerts </dev/null | grep -E "s:|i:|Verify"
 0 s:CN=printer.example.internal
   i:C=US, O=Let's Encrypt, CN=YR2
    Verify return code: 21 (unable to verify the first certificate)

$ curl -o /dev/null -w "%{http_code} %{ssl_verify_result}\n" https://printer.example.internal/   # macOS
200 0

$ ipptool -tv ipps://printer.example.internal/ipp/print get-printer-attributes.test | grep PASS
    Get printer attributes using get-printer-attributes                  [PASS]

The leaf-only problem

Note OpenSSL’s return code 21. The printer serves exactly one certificate, the leaf, even though the PFX contained the whole chain. Installing the intermediate and root in the printer’s CA store does not change that, nor does a power cycle. As far as I can tell FutureSmart never sends intermediates.

Apple clients work because the Security framework follows the leaf’s AIA pointer and downloads the intermediate itself, so Safari, AirPrint and macOS curl are fine. Chrome and Firefox fetch or cache intermediates too. Strict clients fail: OpenSSL, Linux curl, Python requests, Go. For those, hand the client the intermediate via SSL_CERT_FILE or a CA bundle that includes it, or print over plain IPP on 631 instead of IPPS.

If your goal is AirPrint and an admin page without a browser warning, this is good enough. If you need Linux CUPS clients to verify IPPS, the printer alone will not get you there.

Automate renewal

Let’s Encrypt certificates last 90 days and certbot renews at 60. A daily cron runs certbot renew in the container. The renewal config carries a deploy hook, which certbot only runs when a certificate was actually renewed, so the printer is touched every ~60 days while the cron runs daily. A fixed “every 60 days” job can fire a day before certbot’s window opens and push nothing.

The hook builds the PFX, runs a Node script that drives the web interface in headless Chromium (import, select, Use for Network Identity, prune old certificates), then checks what the printer actually serves over TLS.

Why a browser: the form posts to /hp/device/CertificatesTabs/Save, and that handler answers HTTP 500 to anything scripted, with cookies, CSRF token and browser headers all in place. The sign-in form on the same server accepts scripted POSTs fine, so it is specific to that handler. Rather than reverse engineer it, let Chromium be the browser it expects.

The deploy hook, /usr/local/sbin/printer-install-cert:

#!/bin/sh
set -eu
HP=${HP:-printer.example.internal}
LINEAGE=${RENEWED_LINEAGE:-/etc/letsencrypt/live/printer.example.internal}  # certbot sets RENEWED_LINEAGE
EWS_PASS=/etc/printer-cert/ews-password                                   # admin password, 0600
LOGDIR=/var/log/printer-cert
UPLOADER=/usr/local/lib/printer-cert/ews-upload.js

mkdir -p "$LOGDIR"
exec >>"$LOGDIR/install.log" 2>&1
echo "== $(date -Iseconds) start lineage=$LINEAGE"

WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT; umask 077
openssl rand -base64 18 >"$WORK/pass"
openssl pkcs12 -export -legacy \
  -inkey "$LINEAGE/privkey.pem" -in "$LINEAGE/cert.pem" -certfile "$LINEAGE/chain.pem" \
  -name "$HP" -passout "file:$WORK/pass" -out "$WORK/printer.pfx"
SHA1=$(openssl x509 -in "$LINEAGE/cert.pem" -noout -fingerprint -sha1 | sed 's/.*=//; s/://g')

HP_SHOT_DIR=$LOGDIR node "$UPLOADER" "$HP" "$WORK/printer.pfx" "$WORK/pass" "$SHA1" "$EWS_PASS"

# the web server restarts after "Use for Network Identity"; poll until it serves the new leaf
i=0
while [ "$i" -lt 18 ]; do
  SERVED=$(echo | openssl s_client -connect "$HP:443" -servername "$HP" 2>/dev/null \
    | openssl x509 -noout -fingerprint -sha1 2>/dev/null | sed 's/.*=//; s/://g' || true)
  [ "$SERVED" = "$SHA1" ] && { echo "OK printer serves $SHA1"; exit 0; }
  i=$((i + 1)); sleep 10
done
echo "FAIL printer serves '${SERVED:-nothing}', expected $SHA1"; exit 1

The PFX password is random per run and never stored. A non-zero exit makes certbot renew report the hook failure, and the log plus screenshots show where it stopped.

The web interface is regular enough to automate once you know four things about it:

  1. Identity certificates are radio buttons whose value is ID|<SHA-1 fingerprint>, upper-case hex without colons. The script computes the leaf’s fingerprint and finds its certificate without parsing any text.
  2. Use for Network Identity goes through a confirmation page whose button is Continue. After that the web server restarts, so the navigation never completes. The script does not wait for it; the hook verifies over TLS.
  3. The button is disabled when the selected certificate is already the network identity. Check disabled before clicking, or you wait for a navigation that never comes.
  4. Remove has its own confirmation page whose button is Delete, not OK. The script deletes every other identity certificate issued to the same name so renewals do not pile up.

The core of ews-upload.js:

const puppeteer = require('puppeteer-core');
const [host, pfx, pfxPassFile, sha1, ewsPassFile] = process.argv.slice(2);
const base = `https://${host}`;
const certsUrl = `${base}/hp/device/CertificatesTabs/Index`;
const radioSel = `input[name="certificatesRadioList"][value="ID|${sha1}"]`;

const browser = await puppeteer.launch({
  executablePath: '/usr/bin/chromium-browser',
  args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
         '--ignore-certificate-errors', '--disable-features=AsyncDns'],
});
const page = await browser.newPage();
const nav = () => page.waitForNavigation({ waitUntil: 'load' });
async function confirmIfAsked(labels) {              // "Continue" or "Delete" pages
  const btn = await page.$(labels.map((l) => `input[type="submit"][value="${l}"]`).join(', '));
  if (!btn) return false;
  await Promise.all([nav().catch(() => {}), btn.click()]);
  return true;
}

if (ewsPass) {
  await page.goto(`${base}/hp/device/SignIn/Index`, { waitUntil: 'load' });
  await page.type('#PasswordTextBox', ewsPass);
  await Promise.all([nav(), page.click('#signInOk')]);
}

await page.goto(certsUrl, { waitUntil: 'load' });
if (!(await page.$(radioSel))) {                     // import unless already there
  await page.click('#ImportCertificateWithPrivateKey');
  await (await page.$('#SignedCertFile')).uploadFile(pfx);
  await page.type('#certPassword', pfxPass);
  await Promise.all([nav(), page.click('#InstallButton')]);
}

await page.click(radioSel);
if (!(await page.$eval('#UseForNetButton', (b) => b.disabled))) {
  await Promise.all([nav().catch(() => {}), page.click('#UseForNetButton')]);
  await confirmIfAsked(['Continue', 'OK']);          // web server restarts here
}

for (;;) {                                           // prune superseded certificates for this name
  await page.goto(certsUrl, { waitUntil: 'load' });
  const stale = await page.evaluate((host, keep) => {
    for (const r of document.querySelectorAll('input[name="certificatesRadioList"]')) {
      const issuedTo = r.closest('tr')?.querySelector('td:nth-child(2)')?.textContent.trim();
      if (r.value.startsWith('ID|') && r.value !== keep && issuedTo === host) return r.value;
    }
    return null;
  }, host, `ID|${sha1}`);
  if (!stale) break;
  await page.click(`input[name="certificatesRadioList"][value="${stale}"]`);
  await Promise.all([nav(), page.click('#RemoveButton')]);
  if (!(await confirmIfAsked(['Delete']))) break;
}
await browser.close();

The full script also screenshots after every step, retries page loads, and refuses to continue if the certificate it just imported is not in the list. --disable-features=AsyncDns makes Chromium use the system resolver rather than its own.

A real run:

== 2026-10-09T11:08:43+00:00 start lineage=/etc/letsencrypt/live/printer.example.internal
leaf B0A… expires Jan  7 10:07:29 2027 GMT
11:08:45 opening certificate management
11:08:50 importing PFX
11:08:53 import listed
11:08:53 selecting it for network identity
11:08:56 use-for-network: confirmation page, pressing Continue
11:09:04 done; the caller verifies what the printer now serves
OK 2026-10-09T11:09:15+00:00 printer serves B0A…

About 30 seconds, most of it the printer restarting its web server. A second run on the same certificate is idempotent: already present, already the network identity, nothing to prune, verify.

Wire it into certbot by adding the hook to the renewal config:

# /etc/letsencrypt/renewal/printer.example.internal.conf, under [renewalparams]
renew_hook = /usr/local/sbin/printer-install-cert

(certbot reconfigure --deploy-hook … does the same but insists on a successful staging dry run first. Editing the file is what it would have written anyway.)

And the cron, Alpine style, /etc/periodic/daily/certbot-renew:

#!/bin/sh
exec /usr/bin/certbot renew --quiet

Odds and ends

  • Reserve the printer’s DHCP lease or give it a static address. The certificate names a hostname, and the hostname has to keep pointing at the printer.
  • The private key travels. With the PFX approach the key exists on the issuing container and briefly in a temp directory. Keep the key file 0600 and do not let the PFX linger anywhere else.
  • No admin password, telnet on, and raw port 9100 printing are enabled by default. Fix all three while you are in there.