Objective
A certificate signing request is the most misunderstood file in PKI. Engineers treat it as an order form: whatever it asks for is what comes back. It is not an order form. It is a signed statement that says one thing and one thing only, and everything else in it is a suggestion.
By the end of this lab you will be able to state precisely what a request proves, decode one field by field, and show by experiment that the certificate coming out of an authority carries the extensions the authority chose, not the ones the requester wrote down. That distinction is the whole basis of certificate authority policy, and it is the reason an internal CA that blindly copies requested extensions is a security incident waiting for a date.
You will also produce a request for a name you have no right to, watch it verify perfectly, and understand why that is not a defect in the format.
Architecture
One directory, one issuing authority and two requests. The first request is the well-behaved one: a service asking for its own names. The second is the awkward one: the same requester asking for a name that belongs to somebody else. The authority issues against the first request twice, under two different policies, so that you can compare what was asked for with what was granted.
flowchart TD
K["app.key\nEC P-256"] --> C1["app.csr\nasks for two DNS names"]
K --> C2["unauthorised-name.csr\nasks for a name it does not own"]
C1 --> P1["policy A\ngrant both names"]
C1 --> P2["policy B\ngrant one name"]
CA["RunBook Lab Server Issuing CA"] --> P1
CA --> P2
P1 --> A["app-full.crt"]
P2 --> B["app-narrow.crt"]
C2 --> X["never issued\nthe authority refuses"]
Two certificates come out of one request. Nothing about the request changed between them. The only thing that changed is the extension file the authority applied, which is the point the lab exists to make.
Requirements
- OpenSSL 3.5.x. The
-addextoption onopenssl reqand the-extfileoption onopenssl x509 -reqare both used heavily and behave as described on any 3.x build. - A text editor, or a willingness to use the heredocs given below.
- Roughly 1 MB of disk. No network, no containers, no ports.
- No out-of-band access requirement. This lab does not touch SSH, the firewall, the
primary interface, or
/etc/fstab. Nothing outside$HOME/rbpki-lab-03is written.
Scenario
You have inherited an internal certificate authority. The handover notes say issuance is “automated”: a team opens a ticket with a request attached, and a script signs it. There is no review step, because the previous owner reasoned that the request already contains the correct information.
Last week a team submitted a request whose Subject Alternative Name listed their own service and, by copy and paste from an old template, the hostname of the payments gateway. The script issued it. Nobody noticed for six days.
This lab is the postmortem, run forwards. You are going to see exactly how that happens, and exactly which single line of the issuance script would have prevented it.
Tasks
Task 1 — Create the workspace and record the starting state
LAB="$HOME/rbpki-lab-03"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
# Record what Cleanup must restore. This lab starts no services and creates no
# containers, so the only state that can change is the filesystem.
ls -A "$HOME" | sort > "$LAB/state.pre-lab-home"
{
echo "openssl: $(openssl version)"
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$LAB/state.pre-lab"
cat "$LAB/state.pre-lab"
The state.pre-lab-home listing will contain rbpki-lab-03 itself, because the
directory exists by the time ls runs. Cleanup accounts for that.
Task 2 — Stand up the authority that will act on the requests
The authority here is deliberately minimal. Lab 4 builds the proper two-tier version and explains each extension as it is set; the names used are the same, so the two labs describe one lab PKI rather than two.
cd "$HOME/rbpki-lab-03"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 1825 \
-subj "/O=RunBook Academy Lab/CN=RunBook Lab Server Issuing CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-out ca.crt
openssl x509 -in ca.crt -noout -subject -ext basicConstraints,keyUsage
The authority holds ca.key, and that key is the only thing that makes it an authority.
A request cannot compel it, bribe it or bypass it. Everything that follows is about what
the holder of that key chooses to assert.
Task 3 — Build a request that carries a Subject Alternative Name
A request is a small ASN.1 structure: a subject name, a public key, an optional set of
requested attributes, and a signature made with the matching private key over all of
that. -addext is how you get a Subject Alternative Name into the attributes.
cd "$HOME/rbpki-lab-03"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out app.key
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
ls -l app.csr
head -1 app.csr
The -subj value sets the subject distinguished name. It is worth being blunt about
what that Common Name is for: under RFC 9525 a client must not use the Common Name to
decide whether a certificate matches the name it dialled, so the CN here is a label for
humans and inventory systems. The names that will actually be matched are the ones in
subjectAltName, which is why -addext is not optional in any modern issuance.
Task 4 — Decode the request
Read what you just built. -text decodes the structure; -noout suppresses the
re-emitted base64 so that the output is only the decode.
$ openssl req -in app.csr -noout -text Requested Extensions:
X509v3 Subject Alternative Name:
DNS:app.lab.example, DNS:www.app.lab.exampleIllustrative output
That heading is the whole lesson of this lab and it is easy to read past. OpenSSL does not call it “Extensions”. It calls it Requested Extensions, because a PKCS#10 attribute is a message from the requester to the authority, carrying no more authority than the sentence “I would like these”. Nothing in the format gives it force.
The rest of the decode is worth walking through as well:
cd "$HOME/rbpki-lab-03"
# The subject the requester proposes.
openssl req -in app.csr -noout -subject
# The public key the request is built around, digested.
openssl req -in app.csr -noout -pubkey | openssl sha256
# The public half of the private key that signed the request, digested.
openssl pkey -in app.key -pubout | openssl sha256
# Check the request's own signature.
openssl req -in app.csr -noout -verify
The two digests are identical, because the request carries the public key whose private
half signed it. The -verify invocation checks that signature and reports success. Note
carefully what has just been established and what has not: OpenSSL has confirmed that
whoever produced this file held the private key for the public key inside it. It has
confirmed nothing whatever about the subject or the requested names.
Task 5 — Prove that a request carries no entitlement to a name
Make a second request, with the same key, for a name that does not belong to you.
cd "$HOME/rbpki-lab-03"
openssl req -new -key app.key -sha256 \
-subj "/CN=payments.lab.example" \
-addext "subjectAltName=DNS:payments.lab.example" \
-out unauthorised-name.csr
openssl req -in unauthorised-name.csr -noout -subject
openssl req -in unauthorised-name.csr -noout -verify
The verification succeeds. The file is structurally perfect, cryptographically sound and completely illegitimate. There is no field in a request that could have made it otherwise, and no amount of validation of the request itself would catch it, because nothing is wrong with the request.
Task 6 — Issue under a policy that grants everything requested
Now sign the first request. The authority’s policy arrives as an extension file, and that file, not the request, decides what the certificate says.
cd "$HOME/rbpki-lab-03"
cat > policy-full.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,issuer
EOF
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-sha256 -days 90 -extfile policy-full.ext -out app-full.crt
$ openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial -sha256 -days 90 -extfile policy-full.ext -out app-full.crtCertificate request self-signature ok
subject=CN=app.lab.exampleIllustrative output
Read those two lines as a pair. OpenSSL checked the request’s signature and then told you which subject it is about to certify. Neither line says anything about the extensions, because the extensions did not come from the request.
$ openssl x509 -in app-full.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
The Subject Alternative Name here happens to equal the requested one, which is exactly why this experiment on its own proves nothing. It looks like the request was honoured. Task 7 removes that illusion.
Task 7 — Issue the same request under a narrower policy
Change nothing about the request. Change only the policy.
cd "$HOME/rbpki-lab-03"
cat > policy-narrow.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid,issuer
EOF
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-sha256 -days 90 -extfile policy-narrow.ext -out app-narrow.crt
echo "requested by app.csr:"
openssl req -in app.csr -noout -text | grep -A 1 'Subject Alternative Name'
echo "granted in app-narrow.crt:"
openssl x509 -in app-narrow.crt -noout -ext subjectAltName
The request asked for two names. The certificate carries one. The second name is simply
absent, with no error, no warning and no record anywhere in the certificate that it was
ever asked for. A service deployed with app-narrow.crt and dialled as
www.app.lab.example fails hostname verification, and the team that submitted the
request will insist, correctly, that they asked for that name.
Now go one step further and issue with no extension file at all:
cd "$HOME/rbpki-lab-03"
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-sha256 -days 90 -out app-bare.crt
openssl x509 -in app-bare.crt -noout -text | grep -A 3 'X509v3 extensions'
There is no Subject Alternative Name, no Key Usage and no Extended Key Usage. On the 3.5
series openssl x509 -req adds a Subject Key Identifier and an Authority Key Identifier
of its own accord, and nothing else. A certificate in this state is unusable for TLS by
any conforming client, because there is no name to match.
Task 8 — Capture the deliverables
cd "$HOME/rbpki-lab-03"
openssl req -in app.csr -noout -text > csr-decoded.txt
{
echo "# requested versus granted, $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "request: app.csr"
openssl req -in app.csr -noout -text | grep -A 1 'Subject Alternative Name'
echo
echo "issued under policy-full.ext (app-full.crt):"
openssl x509 -in app-full.crt -noout -ext subjectAltName
echo
echo "issued under policy-narrow.ext (app-narrow.crt):"
openssl x509 -in app-narrow.crt -noout -ext subjectAltName
echo
echo "issued with no extension file (app-bare.crt): no subjectAltName present"
echo "verdict: the granted names are a property of the authority, not the request"
} > granted-vs-requested.txt
ls -l csr-decoded.txt granted-vs-requested.txt unauthorised-name.csr
unauthorised-name.csr is a deliverable precisely because it is never issued against.
Keep it as the exhibit you show the next person who says a request can be trusted.
Validation
openssl req -in app.csr -noout -verifyreports the request signature as valid. A failure here means the request andapp.keyhave been separated, which cannot happen if Task 3 ran in one go.openssl req -in app.csr -noout -pubkey | openssl sha256andopenssl pkey -in app.key -pubout | openssl sha256print the same digest.openssl req -in app.csr -noout -textcontains the headingRequested Extensionsand lists both DNS names beneath it.openssl x509 -in app-full.crt -noout -ext subjectAltNamelists two DNS names andopenssl x509 -in app-narrow.crt -noout -ext subjectAltNamelists one. If both list two,policy-narrow.extwas not passed to the second issuance.openssl x509 -in app-bare.crt -noout -textshows no Subject Alternative Name at all.openssl req -in unauthorised-name.csr -noout -verifyalso reports a valid signature, and no certificate namedpaymentsexists in the directory.- The deliverables
csr-decoded.txt,granted-vs-requested.txtandunauthorised-name.csrexist and are non-empty.
Expected Outcome
$HOME/rbpki-lab-03/
├── ca.key ca.crt # the issuing authority
├── app.key # the requester's private key
├── app.csr # requests two DNS names
├── unauthorised-name.csr # requests a name it has no claim to
├── policy-full.ext # authority policy: grant both names
├── policy-narrow.ext # authority policy: grant one name
├── app-full.crt # issued under policy-full.ext
├── app-narrow.crt # issued under policy-narrow.ext
├── app-bare.crt # issued with no policy at all
├── ca.srl # the authority's serial counter
├── csr-decoded.txt # deliverable
├── granted-vs-requested.txt # deliverable
├── state.pre-lab # Cleanup baseline
└── state.pre-lab-home # Cleanup baseline
You can now read any request handed to you, state what it proves and what it merely asserts, and explain to a requester why the certificate they received is not the one they asked for without either of you being wrong.
Troubleshooting
-addext is rejected as an unknown option. The option arrived in OpenSSL 1.1.1. On
an older build the equivalent is a [req] section in a configuration file with
req_extensions pointing at a named section. Check openssl version before working
around anything.
The decoded request shows no requested extensions at all. -addext must appear
before -out on the command line and its argument must be quoted as a single word, so
-addext "subjectAltName=DNS:a,DNS:b" rather than an unquoted value the shell splits on
the comma. Re-run Task 3 and check openssl req -in app.csr -noout -text again.
openssl x509 -req complains that it cannot open the extension file. The -extfile
path is relative to the current working directory, not to the certificate. Every task
here begins with a cd, so the usual cause is running one fence from a different shell.
The issued certificate has a Subject Alternative Name you did not put in the extension
file. Something added -copy_extensions to the invocation, or an openssl.cnf in the
current directory is being picked up and supplying a section. Run with an explicit
-extfile and inspect the result rather than assuming.
grep -A 1 'Subject Alternative Name' prints nothing. The certificate genuinely has
no such extension, which is the expected result for app-bare.crt and a defect anywhere
else. Confirm with the full -text decode before concluding the grep is at fault.
Cleanup
LAB="$HOME/rbpki-lab-03"
# 1. Nothing was started and no container was created, so there is nothing to
# stop. Compare the home directory against the Task 1 capture instead: the
# only expected entry is the lab directory itself, which already existed
# when that listing was taken.
ls -A "$HOME" | sort > "$LAB/state.post-lab-home"
diff "$LAB/state.pre-lab-home" "$LAB/state.post-lab-home"
# 2. Move the baseline somewhere that survives the deletion, so the final
# comparison has something to compare against.
cp "$LAB/state.pre-lab-home" "$HOME/rbpki-lab-03.baseline"
# 3. Remove the lab directory, authority key included.
rm -rf "$LAB"
# 4. The home directory should now differ from the baseline by exactly the two
# entries this Cleanup created and removed.
ls -A "$HOME" | sort | diff "$HOME/rbpki-lab-03.baseline" - || true
rm -f "$HOME/rbpki-lab-03.baseline"
find "$HOME" -maxdepth 1 -name 'rbpki-lab-03' -print
Step 1 must show no difference at all. Step 4 reports only that rbpki-lab-03 has gone
and rbpki-lab-03.baseline has appeared, and the following line removes that file too.
The find at the end must produce no output, which is the restoration assertion: the
only trace of this lab on the host is gone.
Production notes
- Put the extension policy in version control and pass it with
-extfileon every issuance. A policy that lives in somebody’s shell history is not a policy. - Never enable extension copying on an authority that issues to more than one team. The cost of typing the names into the policy file is a few seconds; the cost of the alternative is an unauthorised certificate for whatever the requester typed.
- Log the requested names alongside the granted names for every issuance. When a team reports that their certificate is missing a name, that record turns a two-hour argument into a one-line answer.
- Treat the request as a routing document rather than an instruction: it tells you which key to certify and which team to ask for authorisation, and the authorisation comes from somewhere else entirely.
What You Learned
- A request proves possession of a private key. The signature covers the subject, the public key and the attributes, which stops tampering in flight and proves the requester can use the key being certified.
- A request proves nothing about entitlement to a name. Anyone can sign a syntactically perfect request for any name, and the format has no field that could make that untrue.
- OpenSSL names the section honestly. “Requested Extensions” is not a synonym for extensions; it is the authority’s inbox.
- The granted extensions come from the authority’s policy file. Issue the same request twice under two policies and you get two different certificates, with no record in either of what was originally asked for.
- An authority with no authorisation step has already answered the authorisation question. Automation that signs whatever arrives is not automating issuance, it is automating approval.