Client Certificate Authentication on OpenBSD
Goal: set up a private section of runbsd.me that is not reachable by the public. No login page, no passwords. Instead, use TLS client certificate authentication — the connection is refused at the TLS handshake level if you don't have the right certificate. Nothing to brute force, nothing to phish.
The private section lives at https://runbsd.me:8443/. Port 8443 gets its own server
block in httpd.conf with client ca set. The public site on port 443 is untouched.
The CA lives on my local zimaboard (OpenBSD 7.9-current, not internet-facing), behind OPNsense. The CA private key never touches the Amsterdam VM. Only the CA public certificate gets copied there.
On the zimaboard — set up the CA
Create the directory structure
mkdir -p ~/ca/{private,certs,csr,newcerts}
chmod 700 ~/ca/private
touch ~/ca/index.txt
echo 1000 > ~/ca/serial
Create ca.cnf
Save this to ~/ca/ca.cnf. The important bits are CA:true in the
v3_ca section and clientAuth in v3_client.
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = /home/dhw/ca
certs = $dir/certs
crl_dir = $dir/crl
new_certs_dir = $dir/newcerts
database = $dir/index.txt
serial = $dir/serial
private_key = $dir/private/ca.key
certificate = $dir/certs/ca.crt
default_days = 375
default_md = sha256
preserve = no
policy = policy_strict
[ policy_strict ]
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 4096
distinguished_name = req_distinguished_name
string_mask = utf8only
default_md = sha256
x509_extensions = v3_ca
[ req_distinguished_name ]
countryName = Country Name (2 letter code)
stateOrProvinceName = State or Province Name
localityName = Locality Name
organizationName = Organization Name
organizationalUnitName = Organizational Unit Name
commonName = Common Name
emailAddress = Email Address
[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, keyCertSign, cRLSign
[ v3_client ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
Generate the CA private key
4096-bit RSA, encrypted with AES-256. You'll set a passphrase here — keep it somewhere safe, you need it every time you sign a certificate.
openssl genrsa -aes256 -out ~/ca/private/ca.key 4096
chmod 400 ~/ca/private/ca.key
Generate the CA certificate
Self-signed, valid for 10 years.
openssl req -new -x509 -days 3650 \
-config ~/ca/ca.cnf \
-extensions v3_ca \
-key ~/ca/private/ca.key \
-out ~/ca/certs/ca.crt
Verify the CA certificate
Check for CA:TRUE, Certificate Sign, and 10-year validity.
openssl x509 -in ~/ca/certs/ca.crt -text -noout
On the zimaboard — issue a client certificate
Generate the client key
Elliptic curve (P-256) rather than RSA — smaller and faster, perfectly suited for client certs. Do not cat the key file after generating it.
openssl ecparam -name prime256v1 -genkey -noout \
-out ~/ca/private/dhw-client.key
chmod 400 ~/ca/private/dhw-client.key
Create a certificate signing request
openssl req -new \
-key ~/ca/private/dhw-client.key \
-out ~/ca/csr/dhw-client.csr \
-subj "/C=US/ST=California/L=San Clemente/O=DHW Home CA/CN=DHW Personal Client"
Sign the CSR with your CA
Valid for 2 years. You'll be prompted for your CA passphrase.
openssl x509 -req \
-days 730 \
-in ~/ca/csr/dhw-client.csr \
-CA ~/ca/certs/ca.crt \
-CAkey ~/ca/private/ca.key \
-CAcreateserial \
-out ~/ca/certs/dhw-client.crt \
-extensions v3_client \
-extfile ~/ca/ca.cnf
Verify the client certificate
Check for CA:FALSE, TLS Web Client Authentication, and
Digital Signature.
openssl x509 -in ~/ca/certs/dhw-client.crt -noout -dates -subject -issuer
Bundle into a .p12 for browser import
You'll be prompted for your CA passphrase, then asked to set an export password for the bundle. The export password just needs to survive long enough to import into the browser.
openssl pkcs12 -export \
-out ~/ca/dhw-client.p12 \
-inkey ~/ca/private/dhw-client.key \
-in ~/ca/certs/dhw-client.crt \
-certfile ~/ca/certs/ca.crt \
-name "DHW Personal Client"
chmod 400 ~/ca/dhw-client.p12
Copy files to Mac and Amsterdam VM
Copy the .p12 and CA cert to the Mac
# run these on the Mac
scp dhw@ZIMABOARD_IP:~/ca/dhw-client.p12 ~/Desktop/dhw-client.p12
scp dhw@ZIMABOARD_IP:~/ca/certs/ca.crt ~/Desktop/dhw-ca.crt
Copy the CA public cert to the Amsterdam VM
# run on the Mac
scp ~/Desktop/dhw-ca.crt dhw@runbsd.me:/tmp/dhw-ca.crt
On the Amsterdam VM — configure httpd
Move the CA cert into place
doas mv /tmp/dhw-ca.crt /etc/ssl/dhw-ca.crt
doas chown root:wheel /etc/ssl/dhw-ca.crt
doas chmod 644 /etc/ssl/dhw-ca.crt
Create the private document root
doas mkdir -p /var/www/htdocs/runbsd.me/private
Add a server block to /etc/httpd.conf
This reuses the existing Let's Encrypt certificate. The client ca directive inside
the tls block is what enforces client certificate authentication — note it's
client ca, not just ca. Took me a minute to find that in
man httpd.conf.
server "runbsd.me" {
listen on * tls port 8443
root "/htdocs/runbsd.me/private"
tls {
certificate "/etc/ssl/runbsd.me.fullchain.pem"
key "/etc/ssl/private/runbsd.me.key"
client ca "/etc/ssl/dhw-ca.crt"
}
}
Check syntax and reload
doas httpd -n
doas rcctl reload httpd
No pf changes needed — the default openbsd.amsterdam VM config passes all inbound traffic.
On the Mac — import into Keychain for Safari
Import the client certificate
Double-click dhw-client.p12 on the Desktop. macOS will prompt for the export
password, then ask which keychain — choose login.
Import and trust the CA certificate
Double-click dhw-ca.crt. After it imports, find DHW Personal CA
in Keychain Access, double-click it, expand Trust, and set
When using this certificate to Always Trust. macOS will ask for
your login password to confirm.
Test
Navigate to https://runbsd.me:8443/ in Safari. It will prompt you to select a
certificate — choose DHW Personal Client. You're in.
Clean up the Desktop
rm ~/Desktop/dhw-client.p12
rm ~/Desktop/dhw-ca.crt
Also delete the .p12 from the zimaboard since it has been imported and is no longer needed:
rm ~/ca/dhw-client.p12
Notes
- The CA private key (
~/ca/private/ca.key) never leaves the zimaboard. - The client private key (
~/ca/private/dhw-client.key) never leaves the zimaboard. - Only the CA public certificate (
ca.crt) goes to the Amsterdam VM. - Without the client certificate the TLS handshake fails — no login page, no 403, nothing to attack.
- Client cert expires June 2028. CA cert expires June 2036. Set a calendar reminder.
- To issue a new client cert later (expired, new device, etc.) just repeat the client certificate steps above. The CA stays the same.
- Safari prompted for the keychain password twice on first use — normal behavior. Can set to Always Allow in Keychain Access to suppress future prompts.