Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab · intermediate · ~50 min

Lab 5: Issue a server certificate and put it through the whole verification matrix

C · SimulationB · Nested virtualisation

Objectives

  • Issue an end-entity certificate from an issuing CA under an explicit extension file rather than from whatever the request asked for
  • State what each extension on a leaf certificate authorises, and why the common name no longer identifies the service
  • Prove that the issued certificate and the private key on disk are two halves of one key pair
  • Run the full openssl verify matrix and separate chain failures, name failures and purpose failures from one another

Objective

Lab 4 built an authority. This lab makes it do the only job it exists for: turning a public key and a name into a certificate that a stranger will accept. That sounds like one command, and it is one command. Everything interesting is in the file that command reads, and in the seven ways the result can be refused afterwards.

The file is the extension file. A certificate signing request is a request, in the plain English sense: it carries a public key, a proposed subject, and a wish list of extensions the requester would like. A certificate authority that grants the wish list has not made a decision, it has performed a transcription. You will issue this certificate with an extension file that the CA controls, then read the request and the certificate side by side and see that the CA ignored what it was asked for and asserted what it was willing to assert.

The seven refusals are the second half. openssl verify can be told to check three entirely separate things: that a chain reaches a trust anchor, that a name matches, and that the certificate is permitted to be used the way you intend. Each of those has its own error number, and none of them implies the others. A certificate that passes the chain check and fails the purpose check is a perfectly valid certificate being used for something it was never authorised for, and if you cannot tell that class of failure from an expired chain you will spend an afternoon renewing a certificate that was never the problem.

Architecture

No network, no containers, no services. Four key pairs’ worth of files in one directory, and a single leaf certificate that gets pushed against three different walls.

flowchart TD
    K["app.key\nEC P-256"] --> CSR["app.csr\nrequested extensions"]
    EXT["app.ext\nextensions the CA grants"] --> ISS["openssl x509 -req\nsigned with srv-ca.key"]
    CSR --> ISS
    ISS --> LEAF["app.crt\nCA:FALSE serverAuth\nSAN app.lab.example"]
    LEAF --> V1["chain\n-CAfile plus -untrusted"]
    LEAF --> V2["name\n-verify_hostname"]
    LEAF --> V3["purpose\n-purpose sslserver"]

Read the top half as a funnel with two inlets. The request brings the public key and nothing else that survives; the extension file brings every statement the certificate will make about what the key may do. The bottom half is three independent tests applied to the one artefact that comes out. They share a command name and almost nothing else: you can pass any one of them while failing the other two, which is why the matrix in Task 8 runs each axis on its own rather than in a single invocation that would tell you only that something, somewhere, was wrong.

Requirements

  • OpenSSL 3.5.x, providing openssl genpkey, openssl req, openssl x509 and openssl verify. Every extension name below is 3.x syntax.
  • GNU coreutils for stat, diff and date. Nothing else is needed.
  • Roughly 10 MB of disk. The slowest step is the 4096-bit root key if you rebuild the authority in Task 2; issuing the leaf itself takes well under a second.
  • A shell session you can keep open, so that LAB set in Task 1 is still set at Cleanup.
  • No out-of-band access requirement. This lab does not touch SSH, the firewall, the primary interface, or /etc/fstab. It opens no ports, starts no daemons, and installs nothing into any trust store. Everything it creates lives under one directory named rbpki-lab-05.

Lab 4 produced root.crt, srv-ca.crt and srv-ca.key. If that directory still exists, Task 2 copies those three files across in a few seconds. If you ran Lab 4’s cleanup, Task 2 rebuilds an equivalent authority instead. Either route arrives at the same place, and the rest of the lab does not care which you took.

Scenario

The internal authority from Lab 4 has been signed off and the first real request has arrived. A team wants a certificate for app.lab.example, which they also reach as www.app.lab.example behind the same load balancer, and they have sent you a certificate signing request generated on their own machine.

Their request asks for rather more than they need. It proposes a subject with the organisation name in it, a second name that is not in your estate, and an extended key usage list copied from a blog post. None of that has to be an argument, because none of it is binding. Your job is to issue what the service actually requires, from a policy you control, and then to hand back a certificate along with the evidence that it validates for the purpose it was issued for and refuses to validate for anything else. The extension file is the policy. The verification matrix is the evidence.

Tasks

Task 1 — Create the workspace and record the starting state

LAB="$HOME/rbpki-lab-05"

rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

# One private key is about to be written here. Nothing this shell creates
# should be readable by another account, even briefly.
umask 077

{
  echo "umask now:  $(umask)"
  echo "openssl:    $(openssl version)"
  echo "started at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$LAB/state.pre-lab"

ls -A "$HOME" | sort > "$LAB/state.pre-lab-home"
cat "$LAB/state.pre-lab"

The two state files are what Cleanup is measured against. The home directory listing is the useful one: it turns “I think I removed everything” into a diff that either produces output or does not.

Task 2 — Bring the issuing authority into the workspace

Copy the authority from Lab 4 if you still have it. The certificates are public, but srv-ca.key is not, so it moves with a mode of 600 and stays that way.

LAB="$HOME/rbpki-lab-05"
PREV="$HOME/rbpki-lab-04"
cd "$LAB"

if [ -f "$PREV/srv-ca.key" ]; then
  cp "$PREV/root.crt" "$PREV/srv-ca.crt" "$PREV/srv-ca.key" "$LAB/"
  chmod 600 "$LAB/srv-ca.key"
  echo "authority copied from $PREV"
else
  echo "no lab 4 directory found - run the rebuild block below"
fi

If that reported no directory, rebuild the two tiers. This is the same authority Lab 4 explained at length, compressed into the shortest form that produces it.

LAB="$HOME/rbpki-lab-05"
cd "$LAB"

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root.key
openssl req -x509 -new -key root.key -sha256 -days 3650 \
  -subj "/O=RunBook Academy Lab/CN=RunBook Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash" \
  -out root.crt

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out srv-ca.key
openssl req -new -key srv-ca.key -sha256 \
  -subj "/O=RunBook Academy Lab/CN=RunBook Lab Server Issuing CA" \
  -out srv-ca.csr

cat > srv-ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF

openssl x509 -req -in srv-ca.csr -CA root.crt -CAkey root.key -CAcreateserial \
  -sha256 -days 1825 -extfile srv-ca.ext -out srv-ca.crt

chmod 600 root.key srv-ca.key
openssl verify -CAfile root.crt srv-ca.crt

That last line must print srv-ca.crt: OK before you go any further. Issuing a leaf from an authority you have not verified means a failure in Task 8 could be the leaf, the intermediate or the root, and you will not know which.

Task 3 — Generate the service key

The key belongs to the service, not to the authority. It is generated where the service will run, it never travels, and the CA never sees it. In this lab both roles are the same directory, which is convenient and is also exactly the arrangement you must not reproduce in production.

cd "$HOME/rbpki-lab-05"

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out app.key
chmod 600 app.key
stat -c '%a %n' app.key

P-256 rather than RSA is a deliberate choice for a leaf. An ECDSA signature over the handshake transcript is faster to produce than an RSA one and the key is a fraction of the size, and a leaf is the certificate whose key gets used on every single connection. The authority above it stayed on RSA because compatibility matters more for a certificate that has to be parsed by everything in the estate for a decade. Neither choice is universal; what matters is that you made it on purpose.

Task 4 — Build the request, and note what it can and cannot commit the CA to

cd "$HOME/rbpki-lab-05"

openssl req -new -key app.key -sha256 -subj "/CN=app.lab.example" \
  -addext "subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example" \
  -out app.csr

openssl req -in app.csr -noout -verify
openssl req -in app.csr -noout -text | grep -A3 'Requested Extensions'

openssl req -verify checks the self-signature on the request. That signature proves one thing and one thing only: whoever assembled the request held the private key matching the public key inside it. It says nothing about who they are, nothing about whether they control app.lab.example, and nothing about whether the extensions are appropriate. Proof of possession is a real and necessary check, and it is routinely mistaken for proof of identity.

The heading grep finds is the giveaway. OpenSSL calls the SAN block inside a request Requested Extensions, not Extensions, because that is precisely their status.

Task 5 — Write the extension file, one decision per line

This is the file that decides what the certificate says. Every line below is a separate choice and is worth being able to defend.

basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
  • basicConstraints=critical,CA:FALSE. This is an end entity. CA:FALSE means a signature made by this key on another certificate must be refused by any conforming validator, which is the difference between a compromised web server and a compromised certificate authority. critical forces a validator that does not understand the extension to reject the certificate rather than quietly proceed without the restriction. Note what is absent: no pathlen appears here, and RFC 5280 forbids one, because a path length constraint is only meaningful where cA is TRUE and keyCertSign is asserted.
  • keyUsage=critical,digitalSignature,keyEncipherment. digitalSignature is the one that matters: it authorises the key to sign, which in TLS 1.3 is the CertificateVerify message where the server proves possession by signing the handshake transcript. keyEncipherment authorises the public key to encrypt a symmetric key directly, which is the RSA key-transport exchange of TLS 1.2 and earlier. An ECDSA key cannot do that at all, and TLS 1.3 has no such exchange, so on this certificate the bit is inert. It is included here because the reference capture included it and because you will meet it on almost every leaf you inspect; on a new EC-only estate you can drop it.
  • extendedKeyUsage=serverAuth. The certificate may be presented by a TLS server and by nothing else. This is the line Task 9 makes visible. clientAuth would additionally permit it to be presented by a client, which the mutual TLS lab needs and this service does not. anyExtendedKeyUsage is the tempting catch-all and the CA/Browser Forum Baseline Requirements forbid it outright in a TLS subscriber certificate; do not reach for it in a private PKI either, because it removes the only machine-readable statement of what a certificate is for.
  • subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example. The identity. Both names the service answers to are listed, because a client checks the name it asked for against this list and nothing else. It is not marked critical, and must not be: RFC 5280 requires the SAN to be critical only when the subject is an empty sequence, and this certificate has a subject.
  • subjectKeyIdentifier=hash and authorityKeyIdentifier=keyid:always. The index, not the security. The first derives a short identifier from this certificate’s public key; the second copies the issuer’s subject key identifier in, so a validator holding a store of thousands of certificates can find the issuer without trying each one. always makes OpenSSL fail rather than silently omit the extension if the issuer has none to copy.
cd "$HOME/rbpki-lab-05"

cat > app.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF

cat app.ext

Task 6 — Issue the certificate and read what the CA actually asserted

Configuration changehost - the issuing CA signs the leaf under its own extension policy
$ openssl x509 -req -in app.csr -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial -sha256 -days 90 -extfile app.ext -out app.crt
Certificate request self-signature ok
subject=CN=app.lab.example

Illustrative output

Ninety days is a working choice for an internal certificate, not a rule. The 200-day ceiling that public authorities have been held to since 2026-03-15, dropping again in 2027 and 2029, applies to TLS subscriber certificates issued from the public web PKI and does not bind a private one. What it should do is set your expectations: a lifetime you cannot renew unattended is a lifetime you have chosen to handle by hand, and hands are what miss the renewal.

Read-only / Safehost - the identity, the issuer and the window, read from the certificate itself
$ openssl x509 -in app.crt -noout -subject -issuer -serial -dates
subject=CN=app.lab.example
issuer=O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
serial=21173B360D80F4A69A91164F1067F4F81A1B1B6E
notBefore=Aug 26 21:19:00 2026 GMT
notAfter=Nov 24 21:19:00 2026 GMT

Illustrative output

The issuer names the intermediate, not the root. That is the single most useful line in the output when a chain later fails, because it tells a client which certificate it needs to be given and cannot find on its own.

Read-only / Safehost - the four extensions that decide how this certificate behaves
$ openssl x509 -in app.crt -noout -ext subjectAltName,keyUsage,extendedKeyUsage,basicConstraints
X509v3 Basic Constraints: critical
  CA:FALSE
X509v3 Key Usage: critical
  Digital Signature, Key Encipherment
X509v3 Extended Key Usage:
  TLS Web Server Authentication
X509v3 Subject Alternative Name:
  DNS:app.lab.example, DNS:www.app.lab.example

Illustrative output

Two things to notice. Basic Constraints and Key Usage carry the word critical and Extended Key Usage and Subject Alternative Name do not, exactly as the extension file specified. And the extensions are printed in certificate order rather than in the order you asked for them, which is worth knowing before you write a script that greps for the third line.

Now put the request and the certificate next to each other.

cd "$HOME/rbpki-lab-05"

echo "--- what the request asked for"
openssl req -in app.csr -noout -text | sed -n '/Requested Extensions/,/^ *Signature/p'

echo "--- what the certificate asserts"
openssl x509 -in app.crt -noout -ext subjectAltName,extendedKeyUsage

In this lab the two happen to agree on the subject alternative name, because you wrote the same two names into both files. They agree by construction, not by inheritance. Nothing carried the value across: the -extfile supplied it, and had you written a different SAN into app.ext the certificate would carry that one and the request would be silently overruled. Prove it to yourself by re-issuing with one name removed from app.ext and reading the certificate again.

Task 7 — Prove the certificate and the key are two halves of one pair

Nothing so far has established that app.crt corresponds to app.key. The signature on the certificate was made by the CA’s key, so it proves the CA issued it, not that you hold the matching private half. Serving a certificate whose key you do not have produces a TLS server that starts, listens, and fails every handshake.

Derive the public half twice, from two files that know nothing about each other, and compare. The session below is the reference capture of 2026-08-26; your digest will be a different value, and the two lines must still be identical to each other.

$ openssl pkey -in app.key -pubout | openssl sha256
SHA2-256(stdin)= 75061de3387b8969c6c33ba0931ec0e541ad4c90a4ee2ec3e734e031af66874b
$ openssl x509 -in app.crt -noout -pubkey | openssl sha256
SHA2-256(stdin)= 75061de3387b8969c6c33ba0931ec0e541ad4c90a4ee2ec3e734e031af66874b

The first command reads the private key and derives the public half from it. The second reads the certificate and extracts the public half the CA certified. Neither consults the other file. Both print the same public key in the same encoding and hash it, so the digests are equal if and only if the two files belong together. Comparing modulus values works for RSA and does not generalise; this form works for RSA, ECDSA and Ed25519 without change, which is why it is the one worth memorising.

cd "$HOME/rbpki-lab-05"

KEY_DIGEST=$(openssl pkey -in app.key -pubout | openssl sha256)
CRT_DIGEST=$(openssl x509 -in app.crt -noout -pubkey | openssl sha256)

{
  echo "from app.key: $KEY_DIGEST"
  echo "from app.crt: $CRT_DIGEST"
  if [ "$KEY_DIGEST" = "$CRT_DIGEST" ]; then
    echo "result: the certificate matches the private key"
  else
    echo "result: MISMATCH - do not deploy this pair"
  fi
} > key-match.txt

cat key-match.txt

Task 8 — Run the chain and name axes of the verification matrix

Three of these must succeed and two must be refused. Run them one at a time and read the exit code after each, because the message and the exit code are the two things you will be reading in an incident.

Read-only / Safehost - the complete path: leaf, intermediate supplied alongside, root as the anchor
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt
app.crt: OK

Illustrative output

Read-only / Safehost - the same leaf with the intermediate withheld
$ openssl verify -CAfile root.crt app.crt
error 20 at 0 depth lookup: unable to get local issuer certificate

Illustrative output

Error 20 says the verifier reached depth 0, the leaf, and could not find the certificate that issued it. Nothing is wrong with the leaf. The operator withheld a file. This is why a TLS server must send its intermediates and why Lab 6 spends its time on the bundle rather than on the certificate.

cd "$HOME/rbpki-lab-05"

# Anchor at the intermediate instead of the root. The intermediate is not
# self-signed, so the search continues upward and runs out.
openssl verify -CAfile srv-ca.crt app.crt
echo "exit: $?"

# The name axis, asked for explicitly. Without -verify_hostname no name
# check happens at all.
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
  -verify_hostname app.lab.example app.crt
echo "exit: $?"

openssl verify -CAfile root.crt -untrusted srv-ca.crt \
  -verify_hostname wrong.lab.example app.crt
echo "exit: $?"

Those three produce, in order, error 2 at 1 depth lookup: unable to get issuer certificate with exit 2, then app.crt: OK with exit 0, then error 62 at 0 depth lookup: hostname mismatch with exit 2. Take the first one seriously: naming the intermediate as the anchor looks like it ought to work, and it does not, because an anchor has to be self-signed to end the search. Depth 1 in that message is the intermediate, not the leaf, which tells you immediately that the leaf was fine and the problem is one level up.

Error 62 is a different animal entirely. The chain was complete and trusted, every signature verified, the validity window was current, and the certificate was still refused because it does not assert the name that was asked for. wrong.lab.example is not in the subject alternative name, so there is no match, and no amount of fixing the chain will change that.

Task 9 — Run the purpose axis, and watch a valid certificate be refused

The third axis is the one people forget exists. A certificate can chain correctly and carry the right name and still be unusable, because the extended key usage says it is for something else.

cd "$HOME/rbpki-lab-05"

openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslserver app.crt
echo "exit: $?"

That prints app.crt: OK and exits 0. The certificate carries serverAuth, and sslserver is exactly the use it was issued for. Now ask the same certificate to be a client.

Read-only / Safehost - the same trusted, in-date, correctly-named certificate asked to authenticate a client
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslclient app.crt
error 26 at 0 depth lookup: unsuitable certificate purpose

Illustrative output

Read that failure carefully, because it is the one most often misdiagnosed. The chain is intact. The signatures verify. The dates are current. The name is correct. The certificate is refused because extendedKeyUsage=serverAuth is a statement that this key authenticates a server, and a client presenting it is doing something the authority never sanctioned. If you meet error 26 during a mutual TLS rollout, the fix is a certificate issued with clientAuth in its extended key usage, not a renewal, not a new chain, and certainly not a verification flag turned off.

Note also that Task 8’s very first command, with no -purpose at all, returned OK on this same certificate. By default openssl verify applies no purpose check whatsoever. The default is permissive, so a certificate that passes a bare openssl verify has told you about the chain and nothing else.

Task 10 — Capture the deliverables

cd "$HOME/rbpki-lab-05"

run_case() {
  label="$1"
  shift
  printf '== %s\n' "$label"
  printf '$ %s\n' "$*"
  "$@"
  printf 'exit: %d\n\n' "$?"
}

{
  printf 'verification matrix for app.crt, %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  run_case "chain complete" \
    openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt
  run_case "intermediate withheld" \
    openssl verify -CAfile root.crt app.crt
  run_case "anchored at the intermediate" \
    openssl verify -CAfile srv-ca.crt app.crt
  run_case "hostname matches a SAN entry" \
    openssl verify -CAfile root.crt -untrusted srv-ca.crt \
      -verify_hostname app.lab.example app.crt
  run_case "hostname absent from the SAN" \
    openssl verify -CAfile root.crt -untrusted srv-ca.crt \
      -verify_hostname wrong.lab.example app.crt
  run_case "purpose sslserver" \
    openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslserver app.crt
  run_case "purpose sslclient" \
    openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslclient app.crt
} > verify-matrix.txt 2>&1

{
  echo "# extension policy applied to app.crt"
  echo "# generated $(date -u +%Y-%m-%dT%H:%M:%SZ)"
  echo
  cat app.ext
  echo
  echo "# CA:FALSE      - end entity; a signature by this key on another"
  echo "#                 certificate must be refused. No pathlen is legal here."
  echo "# digitalSignature - signs the TLS 1.3 handshake transcript."
  echo "# keyEncipherment  - RSA key transport only; inert on an EC key."
  echo "# serverAuth    - the only use authorised. sslclient fails, error 26."
  echo "# subjectAltName - the identity. Both service names, non-critical"
  echo "#                 because the subject is not empty."
  echo "# key identifiers - the index a validator uses to find the issuer."
} > app.ext.annotated
mv app.ext.annotated app.ext

chmod 644 app.crt app.ext verify-matrix.txt key-match.txt
ls -l app.crt app.ext verify-matrix.txt key-match.txt
grep -c '^exit: 2' verify-matrix.txt

The final grep -c must report 3. Three of the seven cases are designed to be refused, and a count of anything else means a case did not run or did not fail the way it should have.

Validation

  • openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt prints app.crt: OK and exits 0. Check the code with echo $? immediately afterwards.
  • openssl verify -CAfile root.crt app.crt exits 2 and names error 20. An exit of 0 here means root.crt and srv-ca.crt are the same file and the authority was built wrongly in Task 2.
  • openssl verify -CAfile root.crt -untrusted srv-ca.crt -verify_hostname wrong.lab.example app.crt exits 2 and names error 62.
  • openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslclient app.crt exits 2 and names error 26, while the same command with -purpose sslserver exits 0. Both halves are required: one alone proves nothing.
  • openssl x509 -in app.crt -noout -ext basicConstraints reports CA:FALSE and the word critical.
  • openssl x509 -in app.crt -noout -ext subjectAltName lists both app.lab.example and www.app.lab.example, and does not carry the word critical.
  • The two digests in key-match.txt are identical and the file ends with the matching result line.
  • grep -c '^exit: 2' verify-matrix.txt reports exactly 3.
  • stat -c '%a' app.key srv-ca.key reports 600 for both.
  • The deliverables app.crt, app.ext, verify-matrix.txt and key-match.txt exist and are non-empty.

Expected Outcome

$HOME/rbpki-lab-05/
├── root.crt                  # trust anchor, copied or rebuilt in Task 2
├── srv-ca.crt  srv-ca.key    # the issuing intermediate that signed the leaf
├── srv-ca.srl                # the intermediate's serial counter
├── app.key                   # the service private key, mode 600
├── app.csr                   # the request, and its Requested Extensions
├── app.ext                   # deliverable, the policy with its reasoning
├── app.crt                   # deliverable, the issued leaf
├── verify-matrix.txt         # deliverable, seven cases with exit codes
├── key-match.txt             # deliverable, the two digests
├── state.pre-lab             # Cleanup baseline
└── state.pre-lab-home        # Cleanup baseline

You hold a leaf certificate whose every extension you chose deliberately, and a transcript showing it accepted for the one purpose it was issued for and refused for three distinct reasons. Lab 6 takes app.crt, app.key and srv-ca.crt and puts them on a socket, where the same three axes reappear as messages from curl rather than from openssl verify.

Troubleshooting

openssl x509 -req reports that it cannot open app.ext. The heredoc in Task 5 was run from a different directory than the signing command. Every task begins with a cd for exactly this reason; run it and try again. A missing extension file is worse than an error, because if you also drop the -extfile argument OpenSSL issues a certificate with no extensions at all, which will chain and then be refused by every real client.

The certificate has no subject alternative name. Either -extfile was omitted, or the subjectAltName line was mistyped. OpenSSL does not warn about an extension name it does not recognise in the way you might hope, so read the certificate back with openssl x509 -noout -ext subjectAltName after every issuance rather than assuming.

authorityKeyIdentifier=keyid:always fails during issuance. The issuing certificate has no subject key identifier for OpenSSL to copy. The rebuild block in Task 2 sets one on both authority certificates; an authority built without it needs the intermediate re-issued. This failure is what always is for, and it is preferable to the alternative, which is a certificate silently missing the pointer to its issuer.

Task 8’s first command fails with error 20 as well. -untrusted was pointed at the wrong file, or the copy in Task 2 brought root.crt across but not srv-ca.crt. Run openssl storeutl -noout -certs srv-ca.crt to confirm the file holds one certificate, then openssl x509 -in srv-ca.crt -noout -subject to confirm it is the issuing CA and not a second copy of the root.

The -purpose sslclient case exits 0 instead of 2. The extended key usage line in app.ext included clientAuth, or included anyExtendedKeyUsage, or the extension is absent entirely. An absent extended key usage is treated as unconstrained by RFC 5280, so the purpose check has nothing to refuse. Read it back with openssl x509 -in app.crt -noout -ext extendedKeyUsage and re-issue.

The two digests in Task 7 differ. app.crt was issued from a request built against a different key, which happens easily if Task 3 was run twice. Regenerate the request from the current app.key and re-issue; there is no way to repair a certificate to match a key.

Cleanup

LAB="$HOME/rbpki-lab-05"

# 1. Nothing was started and no port was bound, so confirm rather than stop:
#    the lab authority must be absent from the system trust store.
grep -rl 'RunBook Lab Root CA' /etc/ssl/certs 2>/dev/null || \
  echo "lab authority absent from the system trust store, as expected"

# 2. Keep a copy of the Task 1 baseline somewhere that survives the deletion.
cp "$LAB/state.pre-lab-home" "$HOME/rbpki-lab-05.baseline"

# 3. Remove the directory, the service key and any authority key with it.
rm -rf "$LAB"

# 4. Compare the home directory against the baseline, then remove the baseline.
ls -A "$HOME" | sort | diff "$HOME/rbpki-lab-05.baseline" - || true
rm -f "$HOME/rbpki-lab-05.baseline"

find "$HOME" -maxdepth 1 -name 'rbpki-lab-05*' -print
umask

Step 1 must report the authority absent, the diff in step 4 must produce no output beyond the baseline file itself, and the find must print nothing. Those three together are the restoration assertion: no key material remains and no trust decision was taken on this host. The final umask is a reminder rather than a check, since the tightened value applies only to the shell that ran Task 1 and disappears when you close it.

Production notes

  • Never let the requester choose the extensions. Whatever your issuing pipeline is, the subject alternative names it will sign must be checked against a list of names your organisation actually controls, before the signature, and the check has to be in the pipeline rather than in a reviewer’s head.
  • Issue the narrowest extended key usage that works. A certificate carrying both serverAuth and clientAuth because someone was not sure which was needed is a certificate that can authenticate as either party in your mutual TLS estate if its key is stolen.
  • Generate the key where it will be used, and keep it there. The moment a private key is copied to a second machine, an operator’s laptop included, it is a key you must treat as exposed and schedule for replacement.
  • Choose a validity you can renew automatically. Public authorities are now capped at 200 days, falling to 100 in 2027 and 47 in 2029, and although a private PKI is not bound by those numbers the direction of travel is a strong hint. Anything you cannot renew unattended will eventually be renewed late.
  • Record the verification matrix as part of the issuance record, not just the certificate. When a service fails months later, knowing that the certificate was verified for sslserver and only sslserver on the day it was issued removes an entire branch of the investigation.

What You Learned

  • The extension file is the certificate authority’s decision; the request is only evidence about a key. A CSR proves possession of a private key and nothing else, and a CA that copies its extensions has delegated policy to whoever sent the request.
  • Every extension on a leaf is a separate authorisation. CA:FALSE says it may not issue, digitalSignature says it may sign a handshake, serverAuth says it may be a server and nothing else, and the subject alternative name is the only place the identity lives.
  • The common name identifies nothing. RFC 9525 removed the fallback outright; a name not in the subject alternative name is a name the certificate does not assert.
  • Chain, name and purpose are three independent tests with three error numbers. Error 20 is a missing issuer, error 62 is a name that is not in the SAN, and error 26 is a certificate being asked to do a job it was never authorised for. Each one has a different fix.
  • A bare openssl verify is weaker than a real client. It applies no purpose check and no name check unless you ask for them, so passing it proves the chain and leaves the two most common production failures untested.

Deliverables

  • · app.crt - the issued leaf certificate for app.lab.example, ninety days, carrying a subject alternative name and the serverAuth extended key usage
  • · app.ext - the extension file the issuing CA applied, with the reasoning recorded for every line
  • · verify-matrix.txt - seven verification results with their exit codes, the successes and the refusals side by side
  • · key-match.txt - the two SHA-256 digests that prove the certificate belongs to the private key you generated

Verification status

Last reviewed
2026-08-26
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.