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 x509andopenssl verify. Every extension name below is 3.x syntax. - GNU coreutils for
stat,diffanddate. 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
LABset 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 namedrbpki-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:FALSEmeans 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.criticalforces a validator that does not understand the extension to reject the certificate rather than quietly proceed without the restriction. Note what is absent: nopathlenappears here, and RFC 5280 forbids one, because a path length constraint is only meaningful wherecAis TRUE andkeyCertSignis asserted.keyUsage=critical,digitalSignature,keyEncipherment.digitalSignatureis 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.keyEnciphermentauthorises 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.clientAuthwould additionally permit it to be presented by a client, which the mutual TLS lab needs and this service does not.anyExtendedKeyUsageis 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=hashandauthorityKeyIdentifier=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.alwaysmakes 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
$ openssl x509 -req -in app.csr -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial -sha256 -days 90 -extfile app.ext -out app.crtCertificate request self-signature ok
subject=CN=app.lab.exampleIllustrative 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.
$ openssl x509 -in app.crt -noout -subject -issuer -serial -datessubject=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 GMTIllustrative 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.
$ openssl x509 -in app.crt -noout -ext subjectAltName,keyUsage,extendedKeyUsage,basicConstraintsX509v3 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.exampleIllustrative 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.
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crtapp.crt: OKIllustrative output
$ openssl verify -CAfile root.crt app.crterror 20 at 0 depth lookup: unable to get local issuer certificateIllustrative 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.
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslclient app.crterror 26 at 0 depth lookup: unsuitable certificate purposeIllustrative 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.crtprintsapp.crt: OKand exits 0. Check the code withecho $?immediately afterwards.openssl verify -CAfile root.crt app.crtexits 2 and names error 20. An exit of 0 here meansroot.crtandsrv-ca.crtare 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.crtexits 2 and names error 62.openssl verify -CAfile root.crt -untrusted srv-ca.crt -purpose sslclient app.crtexits 2 and names error 26, while the same command with-purpose sslserverexits 0. Both halves are required: one alone proves nothing.openssl x509 -in app.crt -noout -ext basicConstraintsreportsCA:FALSEand the wordcritical.openssl x509 -in app.crt -noout -ext subjectAltNamelists bothapp.lab.exampleandwww.app.lab.example, and does not carry the wordcritical.- The two digests in
key-match.txtare identical and the file ends with the matching result line. grep -c '^exit: 2' verify-matrix.txtreports exactly 3.stat -c '%a' app.key srv-ca.keyreports600for both.- The deliverables
app.crt,app.ext,verify-matrix.txtandkey-match.txtexist 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
serverAuthandclientAuthbecause 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
sslserverand onlysslserveron 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:FALSEsays it may not issue,digitalSignaturesays it may sign a handshake,serverAuthsays 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 verifyis 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.