A two-tier setup protects the root CA: it signs only intermediate CAs and is unavailable most of the time. Certificates for services are issued by the intermediate CA in Stronghold.

Goal

Deploy a root CA, an intermediate CA in Stronghold, a role for issuing server certificates, CRL and OCSP publishing, and connect consumers: cert-manager and ACME clients.

Prerequisites

Before you start, prepare the following:

  • A Stronghold token with permissions to enable secrets engines and write to pki*/.
  • The jq and openssl utilities on your workstation.
  • An external Stronghold address reachable by clients: https://stronghold.example.com in the examples.
  • A domain for certificates: example.com in the examples.

Step 1. Prepare the root CA

Choose one of the options.

Option A. Offline root CA

The root CA is kept outside Stronghold (for example, on an isolated machine or in an HSM) and is used only to sign intermediate CAs. In this option, there is no pki_root mount in Stronghold, and step 3 is performed with the offline CA tools (openssl ca, openssl x509 -req, or your HSM software). Save the root CA certificate to the root-ca.pem file.

In this option, the root CA private key is not available through the Stronghold API: a compromise of Stronghold or of a Stronghold token does not expose it.

Option B. Root CA in a separate mount

If you do not use an offline CA, keep the root CA in a separate mount that only PKI administrators can access.

  1. Enable the secrets engine and set the maximum lifetime:

    d8 stronghold secrets enable -path=pki_root pki
    d8 stronghold secrets tune -max-lease-ttl=87600h pki_root
  2. Generate the root CA. The private key (internal) never leaves Stronghold:

    d8 stronghold write -field=certificate pki_root/root/generate/internal common_name="Example Root CA" issuer_name="root-2026" ttl=87600h > root-ca.pem
  3. Publish the issuing certificate and CRL addresses of the root CA:

    d8 stronghold write pki_root/config/urls issuing_certificates="https://stronghold.example.com/v1/pki_root/ca" crl_distribution_points="https://stronghold.example.com/v1/pki_root/crl"

For a GOST CA, set the key_type parameter, for example key_type=gost3410-256-paramset-a. The list of allowed values is given in the API reference.

Step 2. Create an intermediate CA

Create a mount for the intermediate CA and a certificate signing request.

  1. Enable a separate mount for the intermediate CA:

    d8 stronghold secrets enable -path=pki_int pki
    d8 stronghold secrets tune -max-lease-ttl=43800h pki_int
  2. Generate a key and a certificate signing request (CSR):

    d8 stronghold write -format=json pki_int/intermediate/generate/internal common_name="Example Intermediate CA" | jq -r '.data.csr' > pki_int.csr

Step 3. Sign the intermediate CA with the root

For option B, run:

d8 stronghold write -format=json pki_root/root/sign-intermediate csr=@pki_int.csr format=pem_bundle ttl=43800h | jq -r '.data.certificate' > pki_int.pem

For option A, sign pki_int.csr on the offline CA and build a pki_int.pem file that contains the intermediate CA certificate and the root CA certificate.

Step 4. Import the signed certificate

Import the signed intermediate CA certificate into the pki_int mount:

d8 stronghold write pki_int/intermediate/set-signed certificate=@pki_int.pem

Set the CRL, OCSP, and issuing certificate addresses that will be written into issued certificates:

d8 stronghold write pki_int/config/urls issuing_certificates="https://stronghold.example.com/v1/pki_int/ca" crl_distribution_points="https://stronghold.example.com/v1/pki_int/crl" ocsp_servers="https://stronghold.example.com/v1/pki_int/ocsp"

Step 5. Create a role

The role restricts domains and certificate lifetime:

d8 stronghold write pki_int/roles/example-com allowed_domains="example.com" allow_subdomains=true max_ttl=720h

Keep max_ttl short: the shorter the lifetime, the less often revocation is needed and the smaller the CRL.

Step 6. Issue a certificate

Issue a certificate for api.example.com with the example-com role:

d8 stronghold write pki_int/issue/example-com common_name="api.example.com" ttl=72h

The response contains certificate, issuing_ca, ca_chain, private_key, and serial_number. If the private key is generated on the client side, send a CSR to the pki_int/sign/example-com endpoint.

Step 7. Configure CRL and OCSP

Configure CRL rebuilding and cleanup of the certificate store.

  1. Enable automatic CRL rebuilding:

    d8 stronghold write pki_int/config/crl auto_rebuild=true expiry=72h
  2. Enable automatic cleanup of expired and revoked certificates:

    d8 stronghold write pki_int/config/auto-tidy enabled=true tidy_cert_store=true tidy_revoked_certs=true safety_buffer=72h
  3. Revoke a certificate by its serial number:

    d8 stronghold write pki_int/revoke serial_number=<SERIAL_NUMBER>

    <SERIAL_NUMBER> is the serial_number value from the issue response.

Step 8. Connect cert-manager

To issue certificates for Ingress and Kubernetes pods, use cert-manager with a vault Issuer pointing to pki_int/sign/example-com. Setting up the Stronghold role and cert-manager resources is described in cert-manager integration.

Step 9. Enable ACME (optional)

The PKI engine supports the ACME protocol, so certificates can be obtained with standard ACME clients.

  1. Set the external mount address used in ACME links:

    d8 stronghold write pki_int/config/cluster path="https://stronghold.example.com/v1/pki_int" aia_path="https://stronghold.example.com/v1/pki_int"
  2. Allow the headers required by the ACME protocol:

    d8 stronghold secrets tune -passthrough-request-headers=If-Modified-Since -allowed-response-headers=Last-Modified -allowed-response-headers=Location -allowed-response-headers=Replay-Nonce -allowed-response-headers=Link pki_int
  3. Enable ACME and restrict roles:

    d8 stronghold write pki_int/config/acme enabled=true allowed_roles="example-com" max_ttl=720h
  4. In the ACME client, use the role directory https://stronghold.example.com/v1/pki_int/roles/example-com/acme/directory. By default, account registration requires external account binding (EAB, the eab_policy parameter).

Verification

Check the result:

  1. Check the chain of an issued certificate:

    d8 stronghold write -format=json pki_int/issue/example-com common_name=test.example.com ttl=1h > test.json
    jq -r '.data.certificate' test.json > test.pem
    jq -r '.data.issuing_ca' test.json > int.pem
    openssl verify -CAfile root-ca.pem -untrusted int.pem test.pem
  2. Check that the CRL is available:

    curl -s https://stronghold.example.com/v1/pki_int/crl/pem | openssl crl -noout -text | head
  3. Check the certificate status via OCSP:

    openssl ocsp -issuer int.pem -VAfile int.pem -cert test.pem -url https://stronghold.example.com/v1/pki_int/ocsp -text

Cleanup

Delete the objects created in this guide.

  1. Revoke test certificates: d8 stronghold write pki_int/revoke serial_number=<SERIAL_NUMBER>, where <SERIAL_NUMBER> is the certificate serial number.
  2. Delete the local files test.json, test.pem, and int.pem.
  3. If the setup was for testing, disable the mounts: d8 stronghold secrets disable pki_int and d8 stronghold secrets disable pki_root. Disabling a mount deletes the CA keys irreversibly.

Additional resources