FalseBlue PKI Integration Guide#

This document explains how to wire the FalseBlue PKI into each platform and service in use. It is also intended as a reusable template for any small business or homelab where self-managed keys are a requirement rather than an option.


PKI Architecture at a Glance#

falseblue.com (Root CA — RSA-2048, offline, expires 2032-04-10)
│
├── FalseBlue Intermediate CA  (step-ca on ereshkigal — ECDSA P-256, expires 2035-08-10)
│   ├── All NixOS host TLS certs (via ACME)
│   ├── SSH host and user certificates
│   └── Ad-hoc leaf certs (iDRAC, RADIUS, custom services)
│
├── YubiKey Nano 5c CA  (ECCP384 — HIGH TRUST, keep at home, expires 2031-06-05)
│   ├── Synology DSM and other appliance certs (manual XCA sign or step ca sign)
│   └── High-assurance personal signing certs
│
└── YubiKey 5c NFC CA  (ECCP384 — LOWER TRUST, on keychain, expires 2031-06-05)
    └── Day-to-day signing where keychain key is acceptable

Which CA to use for what#

Use case Issuing CA Why
NixOS host TLS (automated) step-ca (ACME) Fully automated, 32-day default certs (90-day max), no manual steps
SSH host & user certificates step-ca (JWK or ACME) Short-lived, auto-renewal, audit trail in step-ca DB
Synology DSM / NAS appliances Nano 5c via XCA or step-ca sign Appliance requires cert+key bundle; use the high-trust YubiKey
UniFi controller SSL step-ca (manual or ACME) Server cert only — key stays on UniFi host
UniFi RADIUS/EAP-TLS clients Nano 5c Client auth certs warrant higher-trust CA
Entra CBA (user certificates) Nano 5c User auth certs should be hardware-backed
PKCS#12 for code/document signing Nano 5c High-trust CA for signing artifacts
Tailscale service certs step-ca (ACME) Admin backhaul only — see Access Architecture below
CF tunnel origin certs step-ca (ACME) cloudflared verifies backend cert against falseblue.crt

Access Architecture#

Two distinct access planes exist. They serve different audiences and have different trust models. The PKI is the cert trust source for both internal legs; Entra is the identity trust source for user-facing access.

┌─ ADMIN BACKHAUL ──────────────────────────────────────────────────────────┐
│                                                                            │
│  Admin device ──Tailscale──► ereshkigal / host                            │
│                               ├─ SSH (step-ca host/user cert)             │
│                               ├─ direct service port (Tailscale ACL-gated) │
│                               └─ NixOS ACME cert (*.armadillo-banfish.ts.net) │
│                                                                            │
│  Trust anchor: Tailscale node identity + falseblue.crt for TLS            │
└────────────────────────────────────────────────────────────────────────────┘

┌─ APPLICATION PORTAL ──────────────────────────────────────────────────────┐
│                                                                            │
│  User browser ──HTTPS──► *.inaba.network (Cloudflare-managed cert)        │
│                            │                                               │
│                    Cloudflare Access                                       │
│                    (Entra OIDC — groups + claims gate each app)            │
│                            │                                               │
│                    cloudflared tunnel ──HTTPS──► localhost:PORT            │
│                    (origin cert verified against falseblue.crt)           │
│                            │                                               │
│                    NixOS service (step-ca cert, 32-day default ACME)       │
│                                                                            │
│  Trust anchor: Entra IdP for identity, falseblue.crt for origin TLS       │
└────────────────────────────────────────────────────────────────────────────┘

What this means in practice#

  • Tailscale is ops-only. No user-facing service is exposed solely via Tailscale MagicDNS. It is the break-glass path for admins and the substrate for service-to-service communication.

  • <service>.inaba.network is the user-facing URL (target architecture, not yet deployed). The plan is for every service defined in the flake (jellyfin, servarr, open-webui, etc.) to get a public hostname under inaba.network when it is ready for user access, created via a Cloudflare Tunnel public route. No service is published this way today — no host currently runs services.cloudflared, and the inaba.network portal layer described below is design guidance pending implementation.

  • Entra is the identity source. Cloudflare Access uses Entra as its OIDC provider. Group membership in Entra determines which apps a user can reach. The cert PKI does not gate user identity — falseblue.crt gates transport trust (origin verification), not who you are.

  • The public TLS cert (*.inaba.network) is Cloudflare-managed. Our PKI is not involved in the browser-to-Cloudflare leg. falseblue.crt is used only for the cloudflared-to-origin leg.

  • Cost decision point — Intune vs. Cloudflare Zero Trust. CF Zero Trust is free for up to 50 users; above that it runs ~$7/user/month. Intune (standalone) is ~$8/user/month; if you are already on M365 Business Premium ($22/user), Intune is included. For a small org already in M365, Intune wins on marginal cost once you account for the device management you get. For orgs not yet in M365, CF ZT is the default until the user count or MDM need tips the scale.


Step 0: Root of Trust Distribution#

Every platform integration starts here. Distribute falseblue.crt to each target before anything else. Without it, every cert you issue will be untrusted.

NixOS hosts (automatic)#

The flake already includes falseblue.crt in security.pki.certificateFiles via the PKI module. New hosts pick it up on nixos-rebuild switch.

macOS#

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain pki/authorities/falseblue.crt

Verify: security find-certificate -c "falseblue.com" -a

Windows / Entra-joined devices (GPO or Intune)#

Local import (one machine):

Import-Certificate -FilePath falseblue.crt `
  -CertStoreLocation Cert:\LocalMachine\Root

Domain/Entra-joined fleet (GPO): Computer Configuration → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities → Import falseblue.crt

Intune (Entra-managed devices): Devices → Configuration → New Profile → Trusted Certificate → upload falseblue.crt. Assign to All Devices.

iOS / iPadOS#

Email falseblue.crt to the device (or serve it via HTTPS from falseblue.com). Open it → Settings > General > VPN & Device Management → install the profile. Then: Settings > General > About > Certificate Trust Settings → enable full trust for FalseBlue Root CA.

Android (Pixel/Samsung)#

Settings → Security → More security settings → Encryption & credentials → Install a certificate → CA Certificate → select falseblue.crt.

UniFi OS (Dream Machine / Cloud Gateway)#

SSH into the appliance:

scp pki/authorities/falseblue.crt admin@unifi:/data/etc/certs/falseblue-ca.crt
# In UniFi OS shell:
update-ca-certificates

Synology DSM#

Control Panel → Security → Certificate → Add → Import Certificate → add falseblue.crt as the “Intermediate CA” (even though it is the root; DSM models it this way). This makes DSM trust certs issued by any of the FalseBlue intermediates.


Integration: Tailscale#

What to achieve#

All services accessed over Tailscale get valid HTTPS certificates issued by step-ca rather than self-signed or Let’s Encrypt certs. The Tailscale MagicDNS hostname (e.g. ereshkigal.armadillo-banfish.ts.net) becomes the cert’s primary name.

How it works here#

step-ca’s X.509 policy already allows *.armadillo-banfish.ts.net and 100.0.0.0/8. The NixOS ACME module (tsunaminoai.pki.acme.enable = true) issues via HTTP-01 challenge on port 8888 against ca.falseblue.com.

Extending to non-NixOS Tailscale nodes#

Any Tailscale node that has falseblue.crt in its trust store can request a cert from step-ca:

# Bootstrap (once per machine)
step ca bootstrap \
  --ca-url https://ca.falseblue.com \
  --fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6

# Request a cert (HTTP-01: step-ca must be able to reach :80 on this machine)
step ca certificate "$(hostname).armadillo-banfish.ts.net" \
  server.crt server.key \
  --provisioner acme \
  --san "$(hostname).armadillo-banfish.ts.net"

For automated renewal via systemd:

# One-time setup
step ca renew --daemon server.crt server.key \
  --exec "systemctl reload my-service"

Tailscale Serve / Funnel#

When using tailscale serve, the built-in TLS proxy fetches a cert from Tailscale’s own ACME. That cert is LE-signed and does not need the custom CA. However, if you want to force the custom CA for internal services (e.g. to enforce Entra CBA at the service layer), configure the service to present its own cert and bypass tailscale serve:

# Direct HTTPS with custom cert instead of tailscale serve
step ca certificate "myservice.armadillo-banfish.ts.net" myservice.crt myservice.key \
  --provisioner acme
# Run service on :443 directly, presenting myservice.crt

Integration: Synology DSM#

DSM 7.x supports custom ACME servers. This is the cleanest path if the NAS can reach ca.falseblue.com (port 9443) either via LAN or Tailscale.

Control Panel → Security → Certificate → Add → Get a certificate from Let’s Encrypt → switch to “Custom ACME server” if your DSM version exposes it, otherwise use the CLI:

# SSH into DSM, using step-cli from a Docker container or direct install
step ca bootstrap \
  --ca-url https://ca.falseblue.com \
  --fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6 \
  --install  # adds to DSM system trust store

step ca certificate "synology.falseblue.com" /tmp/synology.crt /tmp/synology.key \
  --provisioner acme \
  --san "synology.falseblue.com" \
  --san "synology.armadillo-banfish.ts.net"

Then import via DSM UI (Option B path below) or synopkg cert import.

Set up a renewal task in DSM Task Scheduler (Control Panel → Task Scheduler) to run step ca renew monthly and reload the web service.

Option B — Manual cert import via XCA (YubiKey Nano 5c)#

Use when the NAS cannot reach step-ca, or when you want the cert signed by the higher-trust YubiKey CA.

  1. In DSM: Control Panel → Security → Certificate → Add → Import Certificate
  2. Generate CSR inside DSM or externally:
# External CSR generation (if DSM does not expose CSR export)
openssl req -new -newkey ec:<(openssl ecparam -name prime256v1) \
  -keyout synology.key -out synology.csr \
  -subj "/CN=synology.falseblue.com/O=FalseBlue"
  1. Sign with step-ca (if reachable) or via XCA (YubiKey Nano 5c):
# Via step-ca
step ca sign synology.csr synology.crt --provisioner "tsunami@falseblue.com"

# Via XCA (GUI): open FalseBlue-CA.xdb.sops → Import CSR → sign with Nano 5c CA
  1. Export chain file (Nano 5c CA + Root):
cat pki/authorities/nanoc5c-CA.crt pki/authorities/falseblue.crt > chain.crt
  1. In DSM Certificate import: upload synology.crt (certificate), synology.key (private key), and chain.crt (intermediate/CA bundle).

  2. Set the new certificate as default for all DSM services (Configure → assign to all).

Certificate scope for Synology#

Include all names the NAS answers to: - synology.falseblue.com (local DNS) - synology.armadillo-banfish.ts.net (Tailscale MagicDNS) - 192.168.0.X (if direct-IP access is used by any service — step-ca’s policy allows 192.168.0.0/24)


Integration: UniFi#

Controller SSL certificate#

The UniFi Network Application (self-hosted) stores its TLS cert in a Java keystore. Replace it with a cert from step-ca.

# On the host running UniFi Network Application
step ca bootstrap \
  --ca-url https://ca.falseblue.com \
  --fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6

step ca certificate "unifi.falseblue.com" unifi.crt unifi.key \
  --provisioner acme \
  --san "unifi.falseblue.com" \
  --san "192.168.0.1"  # controller IP if accessed directly

# Convert to PKCS#12 for Java keystore
openssl pkcs12 -export -in unifi.crt -inkey unifi.key \
  -certfile pki/authorities/falseblue.crt \
  -out unifi.p12 -name "unifi" -passout pass:aircontrolenterprise

# Import into keystore (standard UniFi paths)
keytool -importkeystore \
  -srckeystore unifi.p12 -srcstoretype PKCS12 -srcstorepass aircontrolenterprise \
  -destkeystore /usr/lib/unifi/data/keystore -deststorepass aircontrolenterprise \
  -alias unifi -noprompt

systemctl restart unifi

For UniFi OS appliances (Dream Machine, Cloud Gateway), the workflow differs slightly because UniFi OS manages certs through its own API:

# UniFi OS: place cert + key + chain in:
# /data/unifi-core/config/unifi-core.crt
# /data/unifi-core/config/unifi-core.key
# Then restart: systemctl restart unifi-core

RADIUS / EAP-TLS for WiFi authentication#

EAP-TLS authenticates WiFi clients using certificates instead of passwords. This is the strongest WiFi auth posture available to a small business.

RADIUS server cert (FreeRADIUS or Windows NPS):

# Issue a TLS server cert for the RADIUS server
step ca certificate "radius.falseblue.com" radius.crt radius.key \
  --provisioner "tsunami@falseblue.com" \
  --san "radius.falseblue.com" \
  --san "192.168.0.X"  # RADIUS server IP

Client certificates (one per device/user):

# Issue client auth cert (XCN signed by Nano 5c via XCA for high-trust clients,
# or step-ca for automated fleet issuance)
step ca certificate "user@falseblue.com" client.crt client.key \
  --provisioner "tsunami@falseblue.com" \
  --san "user@falseblue.com"

# Package as PKCS#12 for device import
openssl pkcs12 -export -in client.crt -inkey client.key \
  -certfile pki/authorities/falseblue.crt \
  -out client.p12 -passout pass:changeme

In UniFi Network: - Settings → WiFi → select SSID → Security: WPA3 Enterprise (or WPA2 Enterprise) - RADIUS Profile: point to your FreeRADIUS / NPS server IP, shared secret

On FreeRADIUS, configure eap.conf to use the RADIUS server cert and falseblue.crt as the CA for client verification:

eap {
  tls-config tls-common {
    certificate_file = /etc/freeradius/certs/radius.crt
    private_key_file = /etc/freeradius/certs/radius.key
    CA_file = /etc/freeradius/certs/falseblue.crt
  }
}

Integration: Cloudflare Zero Trust#

Cloudflare Zero Trust (CF ZT) is the application portal layer. It handles user-facing <service>.inaba.network routing, Entra OIDC authentication, and per-app access policies. The PKI’s role here is limited to the cloudflared → origin leg (origin cert verification).

Target design — not yet deployed

This entire CF Zero Trust / Entra OIDC portal is target architecture. No host in the flake currently runs services.cloudflared, and there is no Cloudflare Access or Entra integration wired up yet. The snippets below are the intended implementation.

Architecture for a service (e.g. Jellyfin)#

jellyfin.inaba.network  ← public hostname, CF-managed TLS
      │
      ▼
Cloudflare Access Application
  Policy: Entra group "media-users" (OIDC claim check)
      │
      ▼
cloudflared tunnel on voile
  originCACertFile: /path/to/falseblue.crt
      │
      ▼ HTTPS (step-ca cert, 32-day default ACME)
nginx on ereshkigal → jellyfin on :8096

1. Connect Entra as the OIDC identity provider#

In Cloudflare Zero Trust dashboard: Settings → Authentication → Add new identity provider → Azure AD

Required fields:

  • App ID: from Entra app registration (see below)
  • Client secret: from the app registration
  • Directory ID: your Entra tenant ID

In Entra (portal.azure.com → App registrations → New registration):

  • Name: Cloudflare Zero Trust
  • Redirect URI: https://<your-cf-team>.cloudflareaccess.com/cdn-cgi/access/callback
  • API permissions: openid, profile, email, GroupMember.Read.All
  • Optional: add User.Read to pull group claims into the token

After creating the app, add a client secret (Certificates & secrets → New client secret).

To pass Entra group IDs in OIDC claims (required for CF Access group-based policies): Entra app manifest → set "groupMembershipClaims": "SecurityGroup".

2. Create the tunnel#

In CF Zero Trust dashboard: Networks → Tunnels → Create tunnel → Cloudflared.

On voile (the cloudflared egress host in the modeled topology), install and run cloudflared with the tunnel token:

# modules/nixos/cloudflare-tunnel.nix (add to voile)
services.cloudflared = {
  enable = true;
  tunnels."<tunnel-id>" = {
    credentialsFile = config.sops.secrets."cloudflare/tunnel-credentials".path;
    default = "http_status:404";
    ingress = {
      # Each service gets its own ingress rule
      "jellyfin.inaba.network" = {
        service = "https://localhost:8920";
        originRequest = {
          caPool = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";  # system store includes falseblue.crt
        };
      };
      "radarr.inaba.network".service = "https://localhost:7879";
      "sonarr.inaba.network".service  = "https://localhost:8990";
    };
  };
};

sops.secrets."cloudflare/tunnel-credentials" = {};

The NixOS system trust store already includes falseblue.crt (via the PKI module), so cloudflared running on a NixOS host will trust step-ca origin certs without any extra config. For non-NixOS hosts, explicitly set originRequest.caPool to the path of falseblue.crt.

3. Create Access Applications#

For each service, create a CF Access Application: Access → Applications → Add an application → Self-hosted

Field Value
Application name Jellyfin
Subdomain jellyfin
Domain inaba.network
Session duration 24h (or as appropriate)
Identity providers Azure AD (Entra, configured above)

Access policy (Require group membership):

  • Rule name: Allow media-users
  • Action: Allow
  • Include: Azure Groups → enter the Entra Group Object ID for media-users

The Object ID comes from Entra portal: Groups → select group → Object ID field. CF Access matches this against the groups claim in the Entra OIDC token.

4. Per-service access policy design#

Map Entra groups to CF Access policies. Suggested group structure:

Entra Group CF Access Policy Services
homelab-admins Allow All *.inaba.network apps
media-users Allow jellyfin
homelab-users Allow homepage/homer, open-webui

This keeps authorization entirely in Entra group membership. Adding or removing a user from an Entra group immediately changes their access across all CF-gated services.

5. Adding a new service#

When a new service is added to the flake:

  1. Enable HTTPS for it (via tsunaminoai.pki.acme.enable + nginx vhost — existing mechanism).
  2. Add an ingress rule to the cloudflared tunnel config pointing at its HTTPS port.
  3. Create a CF Access Application for <service>.inaba.network.
  4. Assign the appropriate Entra group policy.

No cert work is required beyond what the ACME module already handles.

Origin cert note#

Cloudflare terminating TLS at the edge and re-originating to the backend is only as secure as the origin connection. Using noTLSVerify: true in the cloudflared ingress config defeats the purpose of having a PKI. Always use verified HTTPS on the origin:

  • Set the backend to https:// in the ingress rule
  • Rely on the NixOS system trust store (which includes falseblue.crt) for verification
  • Never set noTLSVerify: true unless the backend is on loopback and has no cert

Integration: Microsoft Entra ID#

Entra supports Certificate-Based Authentication (CBA), which allows users to sign in using a certificate from your PKI instead of a password.

Target design — not yet deployed

The Entra CBA, Conditional Access, and SCEP integration below is target design. step-ca currently declares only two provisioners (a JWK provisioner tsunami@falseblue.com and an ACME provisioner) — there is no SCEP provisioner configured, and no Entra/CF Access binding exists in the flake yet.

Upload the CA chain to Entra#

Azure Portal: Entra ID → Security → Certificate authorities → Upload

Upload both: 1. pki/authorities/falseblue.crt (mark as Root CA) 2. pki/authorities/nanoc5c-CA.crt (mark as Intermediate CA)

If step-ca–issued certs will also be used for CBA: 3. pki/authorities/step-ca.crt (mark as Intermediate CA)

PowerShell equivalent:

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$cert.Import("falseblue.crt")
New-AzureADTrustedCertificateAuthority -TrustedCertificateAuthority @{
  AuthorityType = "RootAuthority"
  TrustedCertificate = $cert.GetRawCertData()
  CrlDistributionPoint = "https://falseblue.com/falseblue.crl"
}

Issue user certificates for CBA#

Entra CBA requires the User Principal Name (UPN) in the certificate SAN as either otherName (OID 1.3.6.1.4.1.311.20.2.3) or rfc822Name matching the Entra UPN.

# Via step-ca (add the UPN as email SAN — matches Entra's rfc822Name binding)
step ca certificate "user@yourtenant.onmicrosoft.com" user.crt user.key \
  --provisioner "tsunami@falseblue.com" \
  --san "user@yourtenant.onmicrosoft.com"

# Package for smart card / Windows import
openssl pkcs12 -export -in user.crt -inkey user.key \
  -certfile pki/authorities/falseblue.crt \
  -out user.p12

For hardware-backed user certs using the YubiKey Nano 5c: 1. Generate key on YubiKey slot 9A (authentication):

ykman piv keys generate --algorithm ECCP384 9a /tmp/user-pub.pem
ykman piv certificates request \
  --subject "CN=User Name,O=FalseBlue" 9a /tmp/user-pub.pem /tmp/user.csr
2. Sign the CSR using XCA (Nano 5c CA) with the UPN in the SAN. 3. Import the resulting cert back to the YubiKey:
ykman piv certificates import 9a user.crt
4. The YubiKey now presents the user cert for Entra CBA when plugged in.

Configure CBA policy in Entra#

Entra ID → Security → Authentication Methods → Certificate-based authentication:

  • Enable for targeted users/group
  • Configure Certificate User Bindings:
  • “PrincipalName” → maps to UPN field in cert
  • OR “RFC822Name” → maps to email SAN
  • Set affinity binding strength (choose “High affinity” if UPN matches exactly)
  • Optionally require MFA (the cert counts as one factor; set it as single-factor or MFA)

Conditional Access integration#

Once CBA is configured, create a Conditional Access policy:

  • Users: All users (or a pilot group)
  • Conditions: Device platforms, locations as desired
  • Grant: Require authentication strength → “Phishing-resistant MFA” (this includes CBA)

This forces every Entra login to present a certificate from your PKI.

Device certificate enrollment (Intune + SCEP)#

Full SCEP integration requires an on-premises NDES server or a cloud SCEP connector. For small environments, the simpler path is:

  1. Issue a device cert manually via step-ca or XCA.
  2. Deliver via Intune Trusted Certificate + PKCS profile (upload the cert + key as PKCS#12).
  3. Use Intune’s Device Configuration profile to deploy the PKCS#12 to the device cert store.

Automated SCEP (for larger fleets): - step-ca supports a SCEP provisioner (--type scep) — add it to the step-ca configuration. - Configure Intune SCEP profile to point at https://ca.falseblue.com/scep/scep. - Entra-joined devices will auto-enroll their machine certificates on domain join.


Integration: Services in This Flake#

NixOS services (ACME auto-configuration)#

Any NixOS host with tsunaminoai.pki.acme.enable = true gets its Tailscale hostname automatically as a cert. Service modules (servarr, jellyfin, open-webui, doc-pipeline, homer) automatically register nginx HTTPS reverse proxies when this flag is set.

The ACME HTTP-01 challenge is served on :8888; step-ca must be reachable at ca.falseblue.com (via Cloudflare tunnel on voile).

Adding a new NixOS service#

# In your service module, consume the ACME cert via nginx useACMEHost:
services.nginx.virtualHosts."myservice.${config.tsunaminoai.nix.tailscaleDomain}" = {
  useACMEHost = "${config.networking.hostName}.${config.tsunaminoai.nix.tailscaleDomain}";
  forceSSL = true;
  locations."/" = {
    proxyPass = "http://127.0.0.1:${toString port}";
  };
};

The cert files will be at: - /var/lib/acme/<hostname>.<tailscale-domain>/cert.pem - /var/lib/acme/<hostname>.<tailscale-domain>/key.pem - /var/lib/acme/<hostname>.<tailscale-domain>/chain.pem

Non-NixOS services that need a cert (manual)#

For one-off certs (e.g. iDRAC, BMC, managed switches):

# When the device owns its own key (iDRAC, Synology generating internally)
step ca sign device.csr device.crt --provisioner "tsunami@falseblue.com"

# When you control both key and cert
step ca certificate "device.falseblue.com" device.crt device.key \
  --provisioner "tsunami@falseblue.com" \
  --san "device.falseblue.com" \
  --san "192.168.0.X"

SSH certificate authority for the fleet#

step-ca is already configured as an SSH CA. NixOS hosts can be configured to trust it:

# In a host module
services.openssh = {
  trustedUserCAKeys = [ "/etc/ssh/ca.pub" ];  # step-ca user CA public key
};

Retrieve the SSH CA key:

step ca root --ssh-host > /etc/ssh/ca.pub
# Or for user CA:
step ca root --ssh-user > /etc/ssh/user-ca.pub

Issue short-lived SSH certs instead of distributing public keys:

step ssh certificate "tsunami@falseblue.com" id_ecdsa.pub \
  --provisioner "tsunami@falseblue.com" \
  --principal tsunami \
  --not-after 16h


Small Business PKI Deployment Template#

This section distills the FalseBlue setup into a reusable checklist for any small business where self-managed keys are mandatory.

Minimum viable PKI (what you actually need)#

  1. Offline Root CA — one RSA-2048 or ECDSA P-384 cert, key stored in hardware (YubiKey PIV slot 9C) and backed up encrypted offline. 10-year validity. Never directly issues leaf certs.

  2. Online Intermediate CA (step-ca) — issued from the root, runs as a service on a hardened server. Issues all day-to-day leaf certs via ACME. 10-year validity. Private key in sops/vault, unlocked at service start.

  3. ACME automation — all NixOS/Linux services use ACME to get short-lived rotating certs (32-day default, 90-day max in this deployment). No manual cert work for infrastructure. step-ca’s ACME provisioner handles the issuance.

  4. CRL/OCSP distribution — publish falseblue.crt and all CRLs at a stable public URL (https://yourdomain.com/*.crl). Required for Entra CBA and some appliance validation.

  5. Trust store distribution — root cert deployed to every platform via GPO/Intune/MDM before any service cert is issued.

Nice-to-have (add as needed)#

  • YubiKey hardware intermediates — when personal signing (code, email, device auth) needs a higher-trust chain separate from automated infrastructure certs.
  • SCEP provisioner on step-ca — for automated Intune device certificate enrollment.
  • SSH CA — replace SSH key distribution with short-lived SSH certificates from step-ca.
  • ACME DNS-01 — for certs on hosts that cannot serve HTTP challenges (internal-only, behind NAT); uses Cloudflare/Route53 DNS API.

Architecture decisions that matter most#

Decision Recommendation Rationale
Root key storage YubiKey PIV or HSM Never a software key on any networked system
Intermediate key sops-encrypted PEM, decrypted at runtime by step-ca Balance between automation and key protection
Cert lifetimes 32-day default / 90-day max (leaf), 10 yr (intermediate), 10 yr (root) Short leaf = cheap revocation; long CA = stable trust anchors
CRL vs OCSP CRL only for small deployments OCSP requires always-on responder; CRL is simpler and adequate
Name scoping Internal domain + Tailscale domain in SAN Avoids “certificate mismatch” when accessing service by different names
Revocation CRL published on public website Entra and appliances check CDPs in the cert; they must be reachable
Automation ACME for everything that can use it Manual cert ops don’t scale and get forgotten

Critical failure modes to avoid#

  • Root key on a networked system. If the root key is compromised, the entire PKI is compromised. Hardware keys or air-gapped systems only.
  • Intermediate signing leaf certs directly from root. This removes the ability to revoke the intermediate without rebuilding the entire PKI.
  • CRLs that never get updated. Stale CRLs cause authentication failures on platforms that strictly enforce nextUpdate. Run CRL refresh on a cron schedule (every 30 days minimum).
  • Root cert not in trust stores before issuing leaf certs. Every device that needs to trust your certs must have the root installed first. Do this before rolling out HTTPS.
  • Single-provisioner step-ca. Use separate provisioners for ACME (automated), JWK (admin manual), and SCEP (device enrollment). Each has its own rate limits and audit trail.

CRL Distribution Points Reference#

All certificates issued by this PKI should include the following CDP extensions. step-ca handles this automatically; for manually-issued certs, add to the OpenSSL config.

CA CDP URL
falseblue.com Root https://falseblue.com/falseblue.crl
YubiKey Nano 5c https://falseblue.com/nanoc5c-CA.crl
YubiKey NFC 5c https://falseblue.com/nfc5c-CA.crl

step-ca serves its own CRL and OCSP at https://ca.falseblue.com/ — no separate hosting needed for intermediate CA revocation.


Quick Reference: Common Operations#

# Bootstrap a new machine to trust this PKI
step ca bootstrap \
  --ca-url https://ca.falseblue.com \
  --fingerprint 66b8732f7a4630403455da2ef05fef269ae63e390c6b5f50f82ecdf872585fe6 \
  --install

# Request a server cert (ACME, automated)
step ca certificate "myhost.armadillo-banfish.ts.net" server.crt server.key \
  --provisioner acme

# Request a server cert (admin-signed, manual)
step ca certificate "myservice.falseblue.com" server.crt server.key \
  --provisioner "tsunami@falseblue.com" \
  --san "myservice.falseblue.com" --san "192.168.0.X"

# Sign a CSR from a device that generated its own key
step ca sign device.csr device.crt --provisioner "tsunami@falseblue.com"

# Renew a cert (daemon mode — calls reload-cmd on renewal)
step ca renew server.crt server.key --daemon --exec "systemctl reload myservice"

# Inspect a cert
step certificate inspect server.crt

# Revoke a cert
step ca revoke --cert server.crt --provisioner "tsunami@falseblue.com"