DEV Community

POTHURAJU JAYAKRISHNA YADAV for AWS Community Builders

Posted on Edited on

Be Your Own Certificate Authority: Trusted HTTPS on Your Laptop (Windows + WSL2)

1. Why I built my own CA

When you open an HTTPS site, the browser shows whether the connection is secure. It decides this by checking who signed the site's certificate. Operating systems and browsers keep a store of trusted root Certificate Authorities (CAs), such as the roots behind Let's Encrypt or DigiCert. If the site's certificate chains back to one of those roots, the connection is trusted.

I wanted to see this from the inside. So I created my own Root CA, used it to sign a certificate for a test site called mytestsite.local, and made both Chrome and curl trust it.

My setup: a Windows laptop, the server running in WSL2 (Ubuntu 20.04, OpenSSL 1.1.1f), and Chrome on Windows.

Tip on the hostname: I used mytestsite.local, and it worked fine on my machine. If you repeat this, consider mytestsite.test instead. .local is reserved for multicast DNS (mDNS) and can cause name-resolution conflicts on some networks, while .test is reserved for testing (RFC 6761). Just replace the name in every command.

2. How HTTPS trust works

My Local Root CA  (self-signed, CA:TRUE)              <- added to the trust store
        │ signs
        ▼
mytestsite.local  (CA:FALSE, SAN=mytestsite.local)    <- sent by the server
Enter fullscreen mode Exit fullscreen mode

When the browser connects, the server sends its certificate. The browser checks:

  1. Chain: does the certificate chain up to a root in my trust store?
  2. Name: does the certificate's Subject Alternative Name (SAN) match the hostname in the URL?
  3. Dates: is the certificate within its validity period?

If any check fails, you get the "Your connection is not private" page.

Real websites usually have one more link in the chain. The root CA signs an intermediate CA, and the intermediate signs the website certificate. The root key stays offline, so it is rarely exposed. For a local lab, signing directly with the root keeps things simple.

3. Create the Root CA

All files go in one lab folder. Certificates live in certs/, and the server will run from the folder above it:

mkdir -p ~/local-https/certs && cd ~/local-https/certs
Enter fullscreen mode Exit fullscreen mode

First, a small config file that defines the CA's identity and extensions:

cat > ca.cnf <<'EOF'
[req]
distinguished_name = dn
x509_extensions    = v3_ca
prompt             = no
[dn]
CN = My Local Root CA
[v3_ca]
basicConstraints     = critical, CA:TRUE
keyUsage             = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
EOF
Enter fullscreen mode Exit fullscreen mode

Then generate the key and the self-signed root certificate:

openssl genrsa -out myRootCA.key 2048
openssl req -x509 -new -key myRootCA.key -sha256 -days 3650 \
  -out myRootCA.crt -config ca.cnf
Enter fullscreen mode Exit fullscreen mode

What each part does:

  • basicConstraints = CA:TRUE marks this certificate as a Certificate Authority.
  • keyCertSign allows it to sign other certificates. cRLSign allows it to sign Certificate Revocation Lists.
  • -x509 makes the certificate self-signed. A root signs itself because nothing sits above it.
  • openssl genrsa writes the private key without passphrase encryption by default. That's acceptable in a controlled local lab, but protect the file carefully: anyone who obtains it can issue certificates using your CA.

Check the result:

openssl x509 -in myRootCA.crt -noout -text | grep -A1 -E "Basic Constraints|Key Usage"
#   X509v3 Basic Constraints: critical
#       CA:TRUE
#   X509v3 Key Usage: critical
#       Certificate Sign, CRL Sign
Enter fullscreen mode Exit fullscreen mode

What went wrong on my first try: I added the CA extensions with -addext "basicConstraints=critical,CA:TRUE" on the command line. On OpenSSL 1.1.1, the default openssl.cnf already adds basicConstraints, so my root certificate ended up with that extension twice. Signing still worked, but openssl verify failed with "unable to get local issuer certificate". A dedicated config file avoids the duplicate and states every extension explicitly.

4. Generate and sign the server certificate

# Private key + Certificate Signing Request (CSR)
# -nodes matters here: this command creates a new key and would otherwise ask for a passphrase
openssl req -new -nodes -newkey rsa:2048 -keyout mytestsite.local.key \
  -out mytestsite.local.csr -subj "/CN=mytestsite.local"

# Extensions for the server certificate
cat > v3.ext <<EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=mytestsite.local
EOF

# The CA signs the CSR
openssl x509 -req -in mytestsite.local.csr -CA myRootCA.crt -CAkey myRootCA.key \
  -CAcreateserial -out mytestsite.local.crt -days 825 -sha256 -extfile v3.ext
Enter fullscreen mode Exit fullscreen mode

Verify the chain and the hostname:

openssl verify -CAfile myRootCA.crt -verify_hostname mytestsite.local mytestsite.local.crt
# mytestsite.local.crt: OK

openssl verify -CAfile myRootCA.crt -verify_hostname wrong.local mytestsite.local.crt
# error 62 at 0 depth lookup: Hostname mismatch
Enter fullscreen mode Exit fullscreen mode

5. Configure the HTTPS server

Save this as ~/local-https/server.py, one level above certs/:

import http.server, ssl

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"<h1>Hello over trusted HTTPS</h1>"
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("certs/mytestsite.local.crt", "certs/mytestsite.local.key")
httpd = http.server.HTTPServer(("127.0.0.1", 443), Handler)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
httpd.serve_forever()
Enter fullscreen mode Exit fullscreen mode
cd ~/local-https
sudo python3 server.py   # ports below 1024 need root
Enter fullscreen mode Exit fullscreen mode

6. Configure local hostname resolution

mytestsite.local has no DNS record, so I mapped it in the Windows hosts file (C:\Windows\System32\drivers\etc\hosts):

127.0.0.1 mytestsite.local
Enter fullscreen mode Exit fullscreen mode

With the default WSL2 networking mode, Windows can reach WSL services on localhost, so Chrome on Windows reaches the Python server running in WSL.

Inside WSL, curl needs to resolve the name too. Check it first:

getent hosts mytestsite.local
# 127.0.0.1       mytestsite.local
Enter fullscreen mode Exit fullscreen mode

WSL generates its own /etc/hosts (the generateHosts setting in /etc/wsl.conf, enabled by default, Microsoft Learn), and that generated file doesn't include custom Windows entries. On my machine, mytestsite.local still resolved inside WSL even though it wasn't in WSL's /etc/hosts, so WSL's DNS forwarding may have resolved it through Windows. This behavior can depend on the WSL version and networking configuration, so don't rely on it. If getent prints nothing, add the mapping inside WSL as well:

echo "127.0.0.1 mytestsite.local" | sudo tee -a /etc/hosts
Enter fullscreen mode Exit fullscreen mode

With generateHosts enabled, WSL can rewrite /etc/hosts when it restarts. If the entry disappears, add it again or set generateHosts = false.

7. Configure Windows certificate trust

Windows and WSL each have their own trust store, and each trusts only what you add to it.

First, copy the root certificate (only the .crt, never the .key) somewhere Windows can reach it. WSL mounts the C: drive at /mnt/c:

cp ~/local-https/certs/myRootCA.crt /mnt/c/Users/Public/myRootCA.crt
Enter fullscreen mode Exit fullscreen mode

Then import it into the Windows Trusted Root Certification Authorities store. Run PowerShell as Administrator:

Import-Certificate -FilePath "C:\Users\Public\myRootCA.crt" -CertStoreLocation Cert:\LocalMachine\Root
Enter fullscreen mode Exit fullscreen mode

Chrome and Edge use this store. Firefox uses its own store by default.

8. Configure WSL certificate trust

Windows now trusted my CA, but curl inside WSL still failed:

curl: (60) SSL certificate problem: unable to get local issuer certificate
Enter fullscreen mode Exit fullscreen mode

Ubuntu doesn't read the Windows store. To add the CA to Ubuntu's store:

sudo cp ~/local-https/certs/myRootCA.crt /usr/local/share/ca-certificates/myRootCA.crt
sudo update-ca-certificates
# 1 added, 0 removed; done.
Enter fullscreen mode Exit fullscreen mode

The file must end in .crt, or update-ca-certificates skips it. The command also printed rehash: warning: skipping ca-certificates.crt. That warning isn't a problem: the file is the combined bundle the tool builds, not a single certificate.

Python's requests library uses its own CA bundle (certifi) by default. To make it use the Ubuntu system bundle instead:

export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
Enter fullscreen mode Exit fullscreen mode

9. Verify with curl and Chrome

curl https://mytestsite.local/
# <h1>Hello over trusted HTTPS</h1>
Enter fullscreen mode Exit fullscreen mode

curl failing, then succeeding after trusting the CA
curl fails before the CA is trusted in WSL, then succeeds after update-ca-certificates.

After a Chrome restart, the page loaded with no security warning:

Chrome showing the site with a trusted connection
Chrome loads the site with no security warning.

To see the chain, click the icon next to the URL, then Connection is secure → Certificate is valid. It reads My Local Root CA → mytestsite.local.

10. Clean up and security considerations

A trusted root CA is powerful. Anyone with myRootCA.key could issue a certificate for any domain, even google.com, and this laptop would accept it. So I removed the CA when I was done.

WSL:

sudo rm /usr/local/share/ca-certificates/myRootCA.crt
sudo update-ca-certificates --fresh
Enter fullscreen mode Exit fullscreen mode

Windows (PowerShell as Administrator):

Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -eq "CN=My Local Root CA" | Remove-Item
Enter fullscreen mode Exit fullscreen mode

Hosts files: delete the mytestsite.local line from the Windows hosts file, and from WSL's /etc/hosts if you added it there. Also delete C:\Users\Public\myRootCA.crt.

Key files: delete myRootCA.key and the server key, or keep them in a protected location if you plan to reuse the lab.

After cleanup, the same site shows the warning again, which confirms the trust is gone:

Chrome warning after removing the CA
After cleanup, Chrome marks the site "Not secure" and crosses out https. In WSL, curl https://mytestsite.local/ fails again with curl: (60) unable to get local issuer certificate.

11. What I learned

  • HTTPS trust is a chain. The client trusts a root, the root (usually through an intermediate) signs the site, so the client trusts the site.
  • A certificate isn't trusted on its own. Trust depends on what is in the client's trust store.
  • One laptop can have several trust stores: Windows, WSL/Ubuntu, Firefox, and Python's certifi.
  • Browsers match the hostname against the SAN, not the CN.
  • openssl verify -verify_hostname checks the chain and the name in a single command.
  • The root CA private key is the most sensitive file in the whole setup.

Top comments (0)