Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab · intermediate · ~40 min

Lab 3: Create and inspect a certificate signing request

C · SimulationB · Nested virtualisation

Objectives

  • Build a certificate signing request that carries a Subject Alternative Name using addext
  • Decode a request and identify its subject, its public key and its requested extensions
  • Demonstrate that a request proves possession of a private key and nothing about entitlement to a name
  • Issue twice from one request and show that the granted extensions come from the authority, not the requester

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 -addext option on openssl req and the -extfile option on openssl x509 -req are 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-03 is 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.

Read-only / Safehost - the attributes section of the decoded request
$ openssl req -in app.csr -noout -text
        Requested Extensions:
          X509v3 Subject Alternative Name:
              DNS:app.lab.example, DNS:www.app.lab.example

Illustrative 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
Configuration changehost - what OpenSSL reports while signing a request
$ openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial -sha256 -days 90 -extfile policy-full.ext -out app-full.crt
Certificate request self-signature ok
subject=CN=app.lab.example

Illustrative 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.

Read-only / Safehost - the extensions the issued certificate actually carries
$ openssl x509 -in app-full.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

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 -verify reports the request signature as valid. A failure here means the request and app.key have been separated, which cannot happen if Task 3 ran in one go.
  • openssl req -in app.csr -noout -pubkey | openssl sha256 and openssl pkey -in app.key -pubout | openssl sha256 print the same digest.
  • openssl req -in app.csr -noout -text contains the heading Requested Extensions and lists both DNS names beneath it.
  • openssl x509 -in app-full.crt -noout -ext subjectAltName lists two DNS names and openssl x509 -in app-narrow.crt -noout -ext subjectAltName lists one. If both list two, policy-narrow.ext was not passed to the second issuance.
  • openssl x509 -in app-bare.crt -noout -text shows no Subject Alternative Name at all.
  • openssl req -in unauthorised-name.csr -noout -verify also reports a valid signature, and no certificate named payments exists in the directory.
  • The deliverables csr-decoded.txt, granted-vs-requested.txt and unauthorised-name.csr exist 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 -extfile on 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.

Deliverables

  • · csr-decoded.txt — the full decode of the service request, including its requested extensions block
  • · granted-vs-requested.txt — the names the request asked for beside the names each issuance actually granted
  • · unauthorised-name.csr — a structurally perfect request for a name the requester has no claim to

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.