Root Certificate Authority (CA)¶
PKI Overview¶
My internal PKI is built using several different pieces of software, namely OpenSSL1, Smallstep CA2, Microsoft Active Directory Certificate Services, and FreeIPA3.
My Root CA is offline and based on OpenSSL. The private key is generated and
stored on a Yubikey 5 Nano4. The Root CA is restricted to a pathlen
of 1
and is only used to sign subordinated CAs for issuing end entity certificates.
The following is the structure of my internal PKI:
DoubleU Root CA { openssl } [ doubleu.codes ]
├── Home Issuing CA 01 { step-ca } [ home.doubleu.codes ]
├── Home Cloud ACME CA 01 { step-ca } [ cloud.doubleu.codes ]
├── Home AD CA 01 { adcs } [ ad.home.doubleu.codes ]
└── Home IPA CA 01 { dogtag } [ ipa.home.doubleu.codes ]
Subordinate CAs have their own entries.
Root CA Implementation¶
CA files and the database are stored on two USB drives, where one is where CA
actions are taken, and the second is mirrored using rsync
to ensure a safe
backup. Both USB drives contain a 64MB LUKS-encrypted partition that holds only
text files that contain the Yubikey's Management Key (KEY)
, PIN
, and
PIN Unlock Key (PUK)
. These files are generated using urandom
and CA actions
are documented to use the files directly as credentials, meaning the values
contained in these files are never leaked to the environment or command history.
Root CA assets, such as the issuing certificate defined in the Authority Information Access (AIA) extension and the Certificate Revocation List (CRL) Distribution Points (CDP) extension, are made available through a statically generated site on Github Pages (http://ca.doubleu.codes). That site describes how it was set up and why.
Install Dependencies¶
OpenSSL and cryptsetup are already installed on Fedora 35, but there is a few other tools we need.
sudo dnf install -y \
yubikey-manager \
yubico-piv-tool
yubikey-manager
is used to reset, generate private keys, and copy the signed certificate to the Yubikey.yubico-piv-tool
is used to configure the management key, PIN, and PIN unlock key. This can be done fromyubikey-manager
, but changing the management key using that is obnoxious due to length of the default key, which must be provided to change it. It also provideslibykcs11
, which is used by OpenSSL to access the PIV portion of the Yubikey.
Info
You might see tutorials using datefudge
to mask the certificate start
date, which generally corresponds with the time it was created. This is
likely due to those tutorials using openssl req -x509
to create the
private key and self signed certificate at the same time.
req
does not have -startdate
and -enddate
flags like ca
does, so
you need to trick OpenSSL using tools like datefudge
.
Since the steps here use req
to create a Certificate Signing Request (CSR)
and ca
to sign it, -startdate
and -enddate
are available so we don't
need these tools.
Prepare CA Storage¶
Insert your USB drive and use lsblk
to get the device id.
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
. . .
sdb 8:16 1 28.7G 0 disk
└──sdb1 8:17 1 28.7G 0 part /run/media/$USER/14F4-0530
. . .
USB drives generally automount on Fedora Workstation, so be sure to unmount it before doing anything with it.
umount /dev/sdb1
Next, use fdisk
to wipe the drive and create two partitions, one 64MB, and the
other using the rest of the available space. Do this with a one-liner instead of
the interactive prompt. Single quotes without spaces (''
) denote an Enter
keystroke.
printf '%s\n' g n '' '' +64M n '' '' '' w | \
sudo fdisk /dev/sdb
The commands equate to:
g
- Create a new GPT partition tablen
- Create a new partition''
- Partition number, use default 1''
- First sector, use default: beginning of drive+64M
- Last sector, set as 64MB after first sectorn
- Create a new partition''
- Partition number, use default 2''
- First sector, use default: first available after previous partition''
- Last sector, use default: remaining available space on drivew
- Write partition table and exit
Running lsblk
again will show the new drive structure.
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
. . .
sdb 8:16 1 28.7G 0 disk
├──sdb1 8:17 1 64M 0 part
└──sdb2 8:18 1 28.6G 0 part
. . .
Secrets Partition¶
Next, encrypt the 64MB partition using LUKS. Make sure the passphrase is sufficiently long (<= 512 characters), but easy to remember.
Obligatory XKCD
$ sudo cryptsetup luksFormat /dev/sdb1
WARNING!
========
This will overwrite data on /dev/sdb1 irrevocably.
Are you sure? (Type 'yes' in capital letters): YES
Enter passphrase for /dev/sdb1:
Verify passphrase:
Open the new LUKS partition and map it:
sudo cryptsetup open /dev/sdb1 yubirootsec
Next step is to format the partition. I chose FAT32 for both since I don't need anything fancy, and a bit of added "security" of not being able to set execute bits.
sudo mkfs.vfat -v \
-F 32 -n YUBIROOTSEC /dev/mapper/yubirootsec
Finally, close the device. We're going to remove and reinsert it after
formatting the data partition so Gnome will mount the drive in your user space,
sparing the need to use sudo
for every command. Other desktop environments may
do something similar, but I have not tried so YMMV.
sudo cryptsetup close yubirootsec
CA Data Partition¶
As before, format the partition as FAT32.
sudo mkfs.vfat -v -F 32 -n ROOTCA /dev/sdb2
Now remove and reinsert the drive. You should be prompted for the LUKS
partition's passphrase. The drives will be mounted using the filesystem labels
under /run/media/$USER/
:
/run/media/$USER/ROOTCA
/run/media/$USER/YUBIROOTSEC
Prepare Yubikey¶
Change into the YUBIROOTSEC
directory:
cd /run/media/$USER/YUBIROOTSEC
Generate the secrets used to manage the Yubikey and PIV slots.
export LC_CTYPE=C
( \
dd if=/dev/urandom 2>/dev/null | \
tr -d '[:lower:]' | \
tr -cd '[:xdigit:]' | \
fold -w48 | \
head -1 \
) > KEY
( \
dd if=/dev/urandom 2>/dev/null | \
tr -cd '[:digit:]' | \
fold -w6 | \
head -1 \
) > PIN
( \
dd if=/dev/urandom 2>/dev/null | \
tr -cd '[:digit:]' | \
fold -w8 | \
head -1 \
) > PUK
Reset the Yubikey to factory settings.
Danger
If you've previously used this Yubikey, make sure you don't need any of the certificates, or you have backups elsewhere.
ykman piv reset
Apply the secrets you just generated to the Yubikey.
yubico-piv-tool -a set-mgm-key -n $(cat KEY)
yubico-piv-tool -k $(cat KEY) \
-a change-pin -P 123456 -N $(cat PIN)
yubico-piv-tool -k $(cat KEY) \
-a change-puk -P 12345678 -N $(cat PUK)
Generate Root CA Key¶
There are two ways to do this. I've done both, but for my uses prefer "The Risky Way".
I generated my private key on the Yubikey so that it never touches the outside world.
ykman piv keys generate \
-m $(cat KEY) -P $(cat PIN) \
-a ECCP384 9a - > /dev/null
The trailing hyphen (-
) sends the public key to stdout
. We don't need
it, so we're redirecting it to /dev/null
.
This is a bit risky since if something happens to the Yubikey, then I'll have to order a new one and redeploy the entire PKI. If you prefer to have a backup, see "The Safer Way".
To ensure the safety of your private key, get another pair of USB drives, encrypt them, then generate it using OpenSSL.
openssl genpkey \
-algorithm ec \
-pkeyopt ec_paramgen_curve:P-384 \
-pkeyopt ec_param_enc:named_curve \
-aes256
-out root_ca.key.pem
Import the key to the Yubikey.
ykman piv keys import \
-m $(cat KEY) -P $(cat PIN) \
9a root_ca.key.pem
Setup CA Directory¶
Change to the ROOTCA
partition.
cd /run/media/$USER/ROOTCA
Create the file structure.
mkdir ca certs crl db
Generate required files.
( \
dd if=/dev/urandom 2>/dev/null | \
tr -d '[:lower:]' | \
tr -cd '[:xdigit]' | \
fold -w 40 | \
head -1 \
) > db/root_ca.crt.srl
echo 1000 > db/root_ca.crl.srl
touch db/root_ca.db{,.attr}
Create the following three files:
[ default ]
dir = .
dir = $ENV::ROOTCA_DIR
openssl_conf = openssl_def
[ openssl_def ]
engines = engines_def
[ engines_def ]
pkcs11 = pkcs11_def
[ pkcs11_def ]
engine_id = pkcs11
MODULE_PATH = /usr/lib64/libykcs11.so.2
init = 0
#############################################################
[ ca ]
default_ca = root_ca
[ root_ca ]
certificate = $dir/ca/root_ca.crt.pem
private_key = "pkcs11:id=%01;type=private"
new_certs_dir = $dir/certs
serial = $dir/db/root_ca.crt.srl
crlnumber = $dir/db/root_ca.crl.srl
database = $dir/db/root_ca.db
unique_subject = no
default_days = 3652
default_md = sha256
policy = match_pol
email_in_dn = no
preserve = no
name_opt = ca_default
cert_opt = ca_default
copy_extensions = none
default_crl_days = 180
crl_extensions = crl_ext
[ match_pol ]
domainComponent = supplied
countryName = match
stateOrProvinceName = optional
localityName = optional
organizationName = match
organizationalUnitName = optional
commonName = supplied
[ crl_ext ]
authorityKeyIdentifier = keyid:always
#############################################################
[ root_ca_ext ]
keyUsage = critical, keyCertSign, cRLSign
basicConstraints = critical, CA:true, pathlen:1
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
[ issuing_ca_ext ]
keyUsage = critical, keyCertSign, cRLSign
basicConstraints = critical, CA:true, pathlen:0
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
authorityInfoAccess = @issuing_ca_aia
crlDistributionPoints = @issuing_ca_cdp
[ issuing_ca_aia ]
caIssuers;URI.0 = http://ca.doubleu.codes/DoubleU_Root_CA.crl
[ issuing_ca_cdp ]
URI.0 = http://ca.doubleu.codes/DoubleU_Root_CA.crl
export ROOTCA_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
alias rootca="openssl ca \
-config ${ROOTCA_DIR}/openssl.cnf \
-engine pkcs11 \
-keyform engine $@"
unalias rootca 2>/dev/null
unset ROOTCA_DIR
The ROOTCA
directory should look like this now:
.
├── activate
├── ca
├── certs
├── crl
├── db
│ ├── root_ca.crl.srl
│ ├── root_ca.crt.srl
│ ├── root_ca.db
│ └── root_ca.db.attr
├── deactivate
└── openssl.cnf
Generate Root Certificate¶
Request the certificate. Ensure the subj
complies with the policy set in
openssl.cnf
.
openssl req -new \
-engine pkcs11 \
-keyform engine \
-key "pkcs11:id=%01;type=private" \
-subj "/CN=DoubleU Root CA/O=DoubleU Labs/C=US/DC=doubleu/DC=codes" \
-passin file:/run/media/$USER/YUBIROOTSEC/PIN \
-out ca/root_ca.csr.pem
Sign the CSR and pay attention to the startdate
and enddate
. It can be
formatted in one of two ways:
YYMMDDHHMMSSZ
(two digit year, eg.220101000000Z
)YYYYMMDDHHMMSSZ
(four digit year, eg.20220101000000Z
)
Both formats must include seconds (SS
) and the Z
at the end, which denotes
GMT / UTC / Zulu time.
openssl ca \
-config openssl.cnf \
-engine pkcs11 \
-keyform engine \
-selfsign \
-notext \
-passin file:/run/media/$USER/YUBIROOTSEC/PIN \
-in ca/root_ca.csr.pem \
-out ca/root_ca.csr.pem \
-extensions root_ca_ext \
-startdate 202201010000Z \
-enddate 204201010000Z
Import the signed certificate into the Yubikey. Make sure to use the same slot as the private key.
ykman piv certificates import \
-m $(cat /run/media/$USER/YUBIROOTSEC/KEY) \
-P $(cat /run/media/$USER/YUBIROOTSEC/PIN) \
9a ca/root_ca.crt.pem
Show Yubikey PIV info to see the new certificate.
$ ykman piv info
PIN verison: 5.4.3
PIN tries remaining: 3/3
Management key algorithm: TDES
CHUID: [redacted (Card Holder Unique Identifier)]
CCC: No data available
Slot 9a:
Algorithm: ECCP384
Subject DN: CN=DoubleU Root CA,O=DoubleU Labs,C=US,DC=doubleu,DC=codes
Issuer DN: CN=DoubleU Root CA,O=DoubleU Labs,C=US,DC=doubleu,DC=codes
Serial: 123456789012345678901234567890123456789012345678
Fingerprint: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
Not before: 2022-01-01 00:00:00
Not after: 2042-01-01 00:00:00
Convert the PEM encoded certificate to DER since this is what's needed for the Authority Information Access (AIA) extension on certificates issued by the root.
openssl x509 \
-in ca/root_ca.crt.pem \
-out ca/root_ca.crt \
-outform der
Generate CRL¶
Certificate Revocation Lists (CRL) must be accessible by subordinate certificate authorities and their end entities. Mint the first one. It's mostly only consumed in DER format, so directly render it as such.
openssl ca \
-config openssl.cnf \
-engine pkcs11 \
-keyform engine \
-gencrl \
-passin file:/run/media/$USER/YUBIROOTSEC/PIN | \
openssl crl -outform der -out crl/root_ca.crl
rootca -gencrl \
-passin file:/run/media/$USER/YUBIROOTSEC/PIN | \
openssl crl -outform der -out crl/root_ca.crl
openssl.cnf
sets the default expiration date at 180 days, which is roughly
six months. A good practice is to issue a new CRL about ⅔ through
the validity period, so effectivly every 120 days, or roughly four months.
Signing Issuing/Intermediate CA¶
We aren't signing anything right now, but this is how we'll sign issuing CA CSRs when the time comes.
source /run/media/$USER/ROOTCA/activate
activate
sets an environment variable containing the absolute path of the root
CA directory, which is used by the rootca
alias and openssl.cnf
. This means
rootca
can be used from any directory, so you can work with subordinate CSRs
without having work directly in the ROOTCA
directory.
rootca \
-notext \
-passin file:/run/media/$USER/YUBIROOTSEC/PIN \
-in intermediate_ca.csr \
-out intermediate_ca.crt.pem \
-extensions issuing_ca_ext
Make sure the Distinguished Name in the request matches the policy in
openssl.cnf
, in my case:
domainComponent
/DC
must be setcountryName
/C
must beUS
organizationName
/O
must beDoubleU Labs
commonName
/CN
must be set
source /run/media/$USER/deactivate
deactivate
removes the rootca
alias and the environment variable that stores
the absolute path of the CA.
Sync Backup USB¶
Insert the second USB drive and format it the same as the first, including identical partition labels. Be sure to also do the remove and reinsert steps to mount the secondary in the current userspace.
Partitions on the secondary drive will be mounted in directories with an
incremented digit, (ROOTCA1
and YUBIROOTSEC1
).
rsync -a \
/run/media/$USER/ROOTCA/ \
/run/media/$USER/ROOTCA1
rsync -a \
/run/media/$USER/YUBIROOTSEC/ \
/run/media/$USER/YUBIROOTSEC1
The trailing slash on the source path is critical or else the parent directory will be copied instead of only the directory contents.
Cleanup¶
Unmount the USB partitions from Gnome Files, or with the following:
umount /run/media/$USER/{ROOTCA,ROOTCA1,YUBIROOTSEC,YUBIROOTSEC1}
sudo cryptsetup close \
$(lsblk -o NAME /dev/sdb | grep -oe "luks.*")
Publish CRT and CRL¶
Copy ca/root_ca.crt
and crl/root_ca.crl
to the distribution point you set up
and can receive requests on the URI specified by authorityInfoAccess
and
crlDistributionPoints
. Make sure the file name matches the URI as well.
I'm using a statically built site on Github Pages to serve this purpose: http://ca.doubleu.codes/
Created: March 24, 2022