quarkusio/quarkus · error · IllegalArgumentException

The private key cannot be null

Error message

The private key cannot be null

What it means

LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem persists the ACME-issued private key and certificate chain as PEM files. As a fail-fast precondition it throws IllegalArgumentException when the PrivateKey argument is null. This almost always means the upstream ACME result (X509CertificateChainAndSigningKey.getSigningKey()) produced no signing key, i.e. certificate issuance did not actually complete.

Source

Thrown at extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/LetsEncryptHelpers.java:43

import org.wildfly.security.x500.cert.acme.AcmeAccount;
import org.wildfly.security.x500.cert.acme.AcmeException;

import io.smallrye.certs.CertificateUtils;
import io.vertx.core.json.JsonObject;

public class LetsEncryptHelpers {

    public static final String DEFAULT_ACME_URL = "https://acme-v02.api.letsencrypt.org/directory";
    public static final String DEFAULT_ACME_STAGING_URL = "https://acme-staging-v02.api.letsencrypt.org/directory";
    public static final String TLS_AUDIT_LOG = "io.quarkus.tls.audit";

    static Logger LOGGER = Logger.getLogger(LetsEncryptHelpers.class);
    public static Logger AUDIT = Logger.getLogger(LetsEncryptHelpers.TLS_AUDIT_LOG);

    public static void writePrivateKeyAndCertificateChainsAsPem(PrivateKey pk, X509Certificate[] chain, File privateKeyFile,
            File certificateChainFile) throws Exception {
        if (pk == null) {
            throw new IllegalArgumentException("The private key cannot be null");
        }
        if (chain == null || chain.length == 0) {
            throw new IllegalArgumentException("The certificate chain cannot be null or empty");
        }

        AUDIT.debug("Writing private key to file: " + privateKeyFile.getAbsolutePath());
        CertificateUtils.writePrivateKeyToPem(pk, null, privateKeyFile);

        AUDIT.debug("Writing certificate chain to file: " + certificateChainFile.getAbsolutePath());

        if (chain.length == 1) {
            CertificateUtils.writeCertificateToPEM(chain[0], certificateChainFile);
            return;
        }

        // For some reason the method from CertificateUtils distinguishes the first certificate and the rest of the chain
        X509Certificate[] restOfTheChain = new X509Certificate[chain.length - 1];
        System.arraycopy(chain, 1, restOfTheChain, 0, chain.length - 1);

View on GitHub (pinned to e1c734241f)

Solutions

  1. Null-check the key before calling: if certChainAndPrivateKey.getSigningKey() == null, abort — the certificate issuance failed and must be retried.
  2. Re-run the certificate issuance (lets-encrypt issue/renew) so a complete chain+key is obtained from the ACME server.
  3. Verify the ACME server actually issued a key; with custom servers, test against the Let's Encrypt staging URL first.
  4. Check the account.json in the lets-encrypt directory contains a private-key entry if account-key reuse is involved.

Example fix

// before
LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem(
    certChainAndPrivateKey.getSigningKey(),
    certChainAndPrivateKey.getCertificateChain(), keyPem, chainPem);

// after
PrivateKey key = certChainAndPrivateKey.getSigningKey();
if (key == null || certChainAndPrivateKey.getCertificateChain() == null
        || certChainAndPrivateKey.getCertificateChain().length == 0) {
    throw new IllegalStateException("ACME order did not return a key/certificate; retry issuance");
}
LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem(key,
    certChainAndPrivateKey.getCertificateChain(), keyPem, chainPem);
Defensive patterns

Strategy: validation

Validate before calling

PrivateKey key = certChainAndPrivateKey.getSigningKey();
if (key == null) {
    throw new IllegalStateException("ACME returned no signing key; issuance failed");
}
LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem(key,
    certChainAndPrivateKey.getCertificateChain(), keyPem, chainPem);

Type guard

static boolean hasUsableSigningKey(X509CertificateChainAndSigningKey r) {
    return r != null && r.getSigningKey() != null
        && r.getCertificateChain() != null && r.getCertificateChain().length > 0;
}

Try / catch

try {
    LetsEncryptHelpers.writePrivateKeyAndCertificateChainsAsPem(pk, chain, keyPem, chainPem);
} catch (IllegalArgumentException e) {
    if (e.getMessage() != null && e.getMessage().contains("private key cannot be null")) {
        // issuance incomplete: log and trigger a fresh obtainCertificateChain
    } else {
        throw e;
    }
}

Prevention

When it happens

Trigger: Calling writePrivateKeyAndCertificateChainsAsPem(null, chain, keyFile, certFile) directly, or via issueCertificate when obtainCertificateChain returns an X509CertificateChainAndSigningKey whose getSigningKey() is null (failed/partial ACME order, empty renewal result).

Common situations: Tooling/tests invoking the helper with an unset key; a custom or broken ACME server returning a chain without a corresponding signing key; calling issueCertificate on an order that silently failed; deserializing a stored account/key pair that is missing the private-key field.

Related errors


AI-assisted analysis of quarkusio/quarkus@e1c734241f (2026-09-05). Data as JSON: /api/errors/9d073610b89ef33f. Report an issue: GitHub.