../

SSH, keys & certificates

SSH keys, client config, agents, tunnels and server hardening, then TLS certificates: X.509, openssl, ACME, local dev certs and mTLS. Targets OpenSSH 10.x and OpenSSL 3.x. Server admin basics are in Linux sysadmin.

Keys & ssh-keygen

Type-tUse
Ed25519ed25519the default since OpenSSH 9.5; small, fast, no parameter choices
ECDSAecdsa (P-256)when a system (FIPS, some HSMs) lacks Ed25519
RSArsa -b 4096legacy peers only; SHA-1 ssh-rsa signatures are disabled, rsa-sha2-* are used
FIDO2 Ed25519ed25519-skprivate key stays on a hardware token (YubiKey); touch per login
FIDO2 ECDSAecdsa-sktokens without Ed25519 support
DSA—removed in OpenSSH 10.0
CommandDoes
ssh-keygen -t ed25519 -C "zach@laptop"new key pair; prompts for path and passphrase
ssh-keygen -t ed25519 -f ~/.ssh/id_workcustom filename
ssh-keygen -a 100 …more KDF rounds: slower passphrase brute force
ssh-keygen -t ed25519-sk -O resident -O verify-requiredFIDO key stored on the token, PIN + touch
ssh-keygen -Kpull resident keys off a token onto a new machine
ssh-keygen -p -f ~/.ssh/id_ed25519change or add a passphrase
ssh-keygen -y -f key > key.pubrebuild the public key from the private one
ssh-keygen -lf key.pubfingerprint (SHA256:…)
ssh-keygen -F host / -R hostfind / remove a host in known_hosts
ssh-keyscan -t ed25519 hostfetch a host key (verify it out of band)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@hostappend your key to the server's authorized_keys

OpenSSH 10 negotiates the post-quantum hybrid mlkem768x25519-sha256 key exchange by default; 10.1+ prints a warning when a server can't do post-quantum key exchange.

~/.ssh layout & permissions

~/.ssh (mode 700)
.ssh/config               # 600  client settingsconfig.d/            # 700  files pulled in by Includeworkknown_hosts          # 644  host keys you have acceptedauthorized_keys      # 600  keys that may log in HEREid_ed25519           # 600  private: never copy/commitid_ed25519.pub       # 644  public half: share freelyid_ed25519-cert.pub  # 644  CA-signed cert (optional)allowed_signers      # 644  who may sign git commitssockets/             # 700  ControlMaster sockets
chmod 700 ~/.ssh ~/.ssh/sockets
chmod 600 ~/.ssh/config ~/.ssh/authorized_keys ~/.ssh/id_*
chmod 644 ~/.ssh/*.pub ~/.ssh/known_hosts
chmod go-w ~  # sshd rejects group-writable homes

authorized_keys options

Prefix a key with options to limit what it may do (deploy keys, backup jobs):

~/.ssh/authorized_keys
ssh-ed25519 AAAAC3Nz... zach@laptop
from="10.0.0.0/8",restrict ssh-ed25519 AAAAC3Nz... ci@runner
command="/usr/local/bin/backup-recv",restrict ssh-ed25519 AAAAC3Nz... backup
restrict,port-forwarding,permitopen="localhost:5432" ssh-ed25519 AAAAC3Nz... db-tunnel

restrict turns off forwarding, PTY and agent; add back only what's needed.

Client config

For each option, ssh uses the first value it finds, so specific Host blocks go at the top and Host * defaults go last. ssh -G host prints the effective config.

~/.ssh/config
Include config.d/*
 
Host bastion
  HostName bastion.example.com
  User zach
 
Host app-1 app-2
  HostName %h.internal.example.com
  User deploy
  ProxyJump bastion
 
Host github-work
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_work
 
Host *
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes
  AddKeysToAgent yes
  IgnoreUnknown UseKeychain
  UseKeychain yes
  ServerAliveInterval 60
  ControlMaster auto
  ControlPath ~/.ssh/sockets/%C
  ControlPersist 10m
OptionMeaning
Host a b *.dev !prodpatterns matched against what you typed; ! negates
Match host x exec "cmd"conditional block (e.g. only on a given network)
HostNamereal address; %h is the typed name
User, Portlogin user, port
IdentityFilekey to offer; may point at a .pub when the private half is in an agent
IdentitiesOnly yesoffer only IdentityFile keys, not every agent key
ProxyJump a,bhop through jump hosts (-J on the CLI)
ControlMaster auto, ControlPath, ControlPersistreuse one connection; later sessions and scp start instantly
ForwardAgent yesexposes your agent to the remote root; prefer ProxyJump
LocalForward 5433 localhost:5432permanent -L
ServerAliveInterval 60keepalives through NAT and idle firewalls
StrictHostKeyChecking accept-newtrust new hosts automatically, still reject changed keys
UseKeychain yesmacOS only; IgnoreUnknown UseKeychain keeps Linux from erroring
Include fileput at the very top; inside a Host block it only applies to that block
RemoteCommand tmux new -A -s main + RequestTTY yesland in a persistent tmux session

Multiplexing commands: ssh -O check host, ssh -O exit host (close the master).

Agents

The agent holds decrypted keys so you type the passphrase once. ssh finds it through SSH_AUTH_SOCK or IdentityAgent.

AgentSetup
OpenSSH ssh-agenteval "$(ssh-agent -s)"; most desktops already start one
macOS Keychainssh-add --apple-use-keychain ~/.ssh/id_ed25519 once, plus UseKeychain yes and AddKeysToAgent yes
1Passwordenable the SSH agent in settings; IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" (macOS) or ~/.1password/agent.sock (Linux)
Secretive (macOS)keys generated in the Secure Enclave, never exportable; IdentityAgent ~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh
FIDO -sk keyswork with any agent; the token does the signing
CommandDoes
ssh-addadd default keys
ssh-add -l / -Llist fingerprints / public keys
ssh-add -t 1h keyexpire after an hour
ssh-add -c keyconfirm each use
ssh-add -d key, ssh-add -Dremove one / all

With 1Password or Secretive, point IdentityFile at the exported .pub and keep IdentitiesOnly yes, so each host gets the right key instead of the agent trying them all.

Port forwarding

FlagDirectionTypical use
-L lport:host:hportlocal port → through server → host:hportreach a private DB or admin UI
-R rport:host:hportserver port → back through you → host:hportexpose a local dev server
-D portlocal SOCKS5 proxy → anywhere the server can reachbrowse as the server
-Nno remote commandtunnel only
-fbackground after authwith -N
-o ExitOnForwardFailure=yesfail if the port can't bindscripts
ssh -L 5433:db.internal:5432 bastion
 
 laptop                      bastion                 db.internal
 psql → 127.0.0.1:5433 ══ssh══► sshd ───tcp───► :5432
ssh -R 8080:localhost:3000 vps
 
 internet → vps:8080 ══ssh══► laptop → localhost:3000
 (vps listens on 127.0.0.1 only unless sshd has GatewayPorts yes)
ssh -D 1080 vps
 
 browser (SOCKS5 127.0.0.1:1080) ══ssh══► vps ───► any host:port

Forwarded ports bind to 127.0.0.1 by default; writing 0.0.0.0:5433:… exposes them to your LAN.

Copying files

CommandDoes
scp f host:/tmp/copy; uses the SFTP protocol since OpenSSH 9.0
scp -r dir host:recursive, into the remote home
rsync -avz --progress src/ host:/srv/app/incremental; trailing / on src/ copies its contents
rsync -avz --delete src/ host:dst/exact mirror: deletes extra files at dst
rsync -avzn …dry run first
rsync -a --exclude node_modules --exclude .git …skip paths
rsync -e 'ssh -p 2222' …custom ssh options
sftp hostinteractive: ls, get, put, lcd, mkdir
sshfs host:/srv /mnt/srvmount a remote dir (FUSE)
tar czf - dir | ssh host 'tar xzf - -C /srv'stream a tree without temp files

All of them honor ~/.ssh/config, including ProxyJump and multiplexing.

sshd hardening

Put settings in a drop-in. sshd also keeps the first value it reads, and sshd_config.d/*.conf is included at the top in name order, so 00- beats cloud images' 50-cloud-init.conf.

DirectiveSet toWhy
PermitRootLoginnolog in as a user, then sudo (default is prohibit-password)
PasswordAuthenticationnokeys only
KbdInteractiveAuthenticationnocloses the PAM password path too
AuthenticationMethodspublickeyor publickey,keyboard-interactive for key + TOTP
AllowGroupsssh-usersallowlist who may log in at all
MaxAuthTries3fewer guesses per connection
LoginGraceTime20drop idle unauthenticated sockets
X11Forwardingnonot needed on servers
AllowAgentForwardingnostops agent hijacking on this host
AllowTcpForwardingno or localunless it's a jump host
ClientAliveInterval, ClientAliveCountMax300, 2reap dead sessions
TrustedUserCAKeys/etc/ssh/user_ca.pubaccept CA-signed user certs
PerSourcePenalties(default on, 9.8+)built-in throttling of abusive source IPs
sudo sshd -t                        # syntax check
sudo sshd -T | grep -Ei 'passw|root|kbd'   # effective values
sudo systemctl reload ssh   # Fedora/RHEL: sshd

SSH certificates

A CA key signs user and host keys with an identity, principals and an expiry. Servers trust the CA instead of per-user authorized_keys; clients trust the CA instead of TOFU host prompts.

# one-time: CA keys (keep offline or in a secrets manager)
ssh-keygen -t ed25519 -f user_ca -C user_ca
ssh-keygen -t ed25519 -f host_ca -C host_ca
 
# user cert: valid 8h for logins as alice or deploy
ssh-keygen -s user_ca -I alice@laptop -n alice,deploy \
  -V +8h ~/.ssh/id_ed25519.pub   # → id_ed25519-cert.pub
ssh-keygen -Lf ~/.ssh/id_ed25519-cert.pub   # inspect
 
# host cert: run with the host's public key
ssh-keygen -s host_ca -I web1 -h \
  -n web1.example.com,10.0.0.5 -V +52w \
  /etc/ssh/ssh_host_ed25519_key.pub
SideConfig
Server trusts user CATrustedUserCAKeys /etc/ssh/user_ca.pub in sshd_config
Server presents host certHostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
Client trusts host CA@cert-authority *.example.com ssh-ed25519 AAAA… in known_hosts
Revokessh-keygen -k -f revoked.krl key.pub; RevokedKeys /etc/ssh/revoked.krl
Map principalsAuthorizedPrincipalsFile /etc/ssh/principals/%u

ssh picks up id_ed25519-cert.pub next to the key automatically. For short-lived certs at scale, use step-ca, Vault's SSH engine or Teleport.

Git over SSH

TaskCommand / config
Test authssh -T git@github.com
Clonegit clone git@github.com:org/repo.git
Second accountHost github-work alias (above), then git@github-work:org/repo.git
Per-repo keygit config core.sshCommand "ssh -i ~/.ssh/id_work -o IdentitiesOnly=yes"
Per-directory identity[includeIf "gitdir:~/work/"] + path = ~/.gitconfig-work in ~/.gitconfig
Upload a keygh ssh-key add ~/.ssh/id_ed25519.pub --type authentication

Signing commits with SSH keys

SettingValue
gpg.formatssh
user.signingkeypath to the .pub, or the key text itself
commit.gpgsign, tag.gpgsigntrue
gpg.ssh.allowedSignersFilefile of email key lines trusted for git log --show-signature
gpg.ssh.program/Applications/1Password.app/Contents/MacOS/op-ssh-sign when 1Password holds the key

GitHub and GitLab show "Verified" only if the key is also uploaded as a signing key (gh ssh-key add --type signing). Setup steps are in the recipe below.

TLS & X.509 basics

Root CA          self-signed; ships in the OS / browser trust store
  │ signs
Intermediate CA  sent by the server
  │ signs
Leaf cert        SAN: example.com, www.example.com; sent by the server
 
The server sends leaf + intermediates ("fullchain"), never the root.
The client builds a path to a root it already trusts.
TermMeaning
CSRcertificate signing request: public key + requested names, signed by your private key
SANSubject Alternative Name: the DNS names/IPs the cert is valid for; clients ignore CN
CNCommon Name in the subject; informational now
EKUextended key usage: serverAuth, clientAuth, codeSigning
SNIclient sends the hostname in the handshake so one IP can serve many certs
ALPNprotocol negotiation inside TLS (h2, http/1.1)
Chain / bundleleaf followed by intermediates in one PEM file
Fingerprinthash of the DER cert; openssl x509 -fingerprint -sha256
Validitypublic TLS certs max 200 days since March 2026, 100 in 2027, 47 in 2029

Formats and extensions

FormatWhatUsual extensions
PEMBase64 between -----BEGIN …----- lines; can hold several objects.pem, .crt, .cer, .key
DERbinary, one object.der, .cer
PKCS#8private key container: BEGIN PRIVATE KEY (or ENCRYPTED).key, .pem
PKCS#1legacy RSA-only key: BEGIN RSA PRIVATE KEY.key
PKCS#10CSR: BEGIN CERTIFICATE REQUEST.csr
PKCS#12 / PFXbinary bundle of key + cert + chain, password-protected.p12, .pfx
PKCS#7certs only, no key (Windows, Java).p7b, .p7c

The extension doesn't tell you the encoding: a .crt may be PEM or DER. head -1 file shows a BEGIN line for PEM.

Trust stores

Debian / Ubuntu
/etc/ssl/certs/                   # trusted CAs (hashed links)ca-certificates.crt  # the bundle most tools readprivate/                 # keys; 710 root:ssl-certopenssl.cnfetc/letsencrypt/live/example.com/fullchain.pem            # leaf + chain: serve thisprivkey.pem              # private keycert.pem                 # leaf onlychain.pem                # intermediates onlyusr/local/share/ca-certificates/         # your own CAs (.crt, PEM)
Add a private CACommand
Debian / Ubuntucopy to /usr/local/share/ca-certificates/corp.crt, run sudo update-ca-certificates
Fedora / RHELcopy to /etc/pki/ca-trust/source/anchors/, run sudo update-ca-trust; bundle is /etc/pki/tls/certs/ca-bundle.crt
macOSsudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain corp.crt
Bun / NodeNODE_EXTRA_CA_CERTS=/path/corp.pem (read once at startup)

openssl commands

TaskCommand
Ed25519 keyopenssl genpkey -algorithm ed25519 -out k.pem
ECDSA P-256 keyopenssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out k.pem
RSA 3072 keyopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out k.pem
CSR with SANsopenssl req -new -key k.pem -subj "/CN=example.com" -addext "subjectAltName=DNS:example.com,DNS:www.example.com" -out r.csr
Read a CSRopenssl req -in r.csr -noout -text
Read a certopenssl x509 -in c.pem -noout -text
Just the essentialsopenssl x509 -in c.pem -noout -subject -issuer -dates -ext subjectAltName
Fingerprintopenssl x509 -in c.pem -noout -fingerprint -sha256
Expires within 30 days?openssl x509 -in c.pem -noout -checkend 2592000 (exit 1 if so)
Remote chainopenssl s_client -connect host:443 -servername host -showcerts </dev/null
Remote summaryopenssl s_client -connect host:443 -servername host -brief </dev/null
STARTTLSopenssl s_client -connect mail:587 -starttls smtp
Verify a chainopenssl verify -CAfile root.pem -untrusted inter.pem leaf.pem
Key matches cert?compare openssl x509 -in c.pem -noout -pubkey with openssl pkey -in k.pem -pubout
PEM → DERopenssl x509 -in c.pem -outform der -out c.der
DER → PEMopenssl x509 -inform der -in c.der -out c.pem
Make PFXopenssl pkcs12 -export -inkey k.pem -in c.pem -certfile chain.pem -out c.pfx
Unpack PFXopenssl pkcs12 -in c.pfx -noenc (add -legacy for old RC2 files)
Strip key passphraseopenssl pkey -in enc.pem -out plain.pem
Random secretopenssl rand -hex 32

-noenc replaces the deprecated -nodes in OpenSSL 3. Browsers don't accept Ed25519 TLS certs, so use ECDSA P-256 (or RSA) for anything a browser will see.

Getting certificates

Let's Encrypt & ACME

ChallengeProves control byGood for
HTTP-01serving a token at http://host/.well-known/acme-challenge/ on port 80public web servers; no wildcards
DNS-01a _acme-challenge TXT recordwildcards, internal hosts, no open ports; needs DNS API creds
TLS-ALPN-01a special cert on port 443proxies like Caddy and Traefik
Profile (Let's Encrypt)Lifetime
classic (default)90 days; 64 from Feb 2027, 45 from Feb 2028
tlsserver45 days (since May 2026)
shortlivedabout 6 days; IP-address certs allowed

Let's Encrypt no longer sends expiry emails and has dropped OCSP, so monitor expiry yourself (recipe below).

# Debian/Ubuntu; on Fedora/RHEL: dnf install (EPEL on RHEL)
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot certonly --webroot -w /var/www/html \
  -d example.com                  # any server, no edits
sudo certbot certonly --standalone -d example.com
sudo certbot certificates         # what's installed
sudo certbot renew --dry-run      # test renewal
systemctl list-timers | grep certbot

Renewal runs from certbot.timer. Put reload commands in /etc/letsencrypt/renewal-hooks/deploy/ (e.g. systemctl reload nginx). Opt in to a profile with --preferred-profile tlsserver. Wildcards need DNS-01 with a plugin such as python3-certbot-dns-cloudflare.

Caddy: automatic HTTPS

/etc/caddy/Caddyfile
example.com, www.example.com {
    reverse_proxy localhost:3000
}

Caddy obtains and renews certificates, redirects HTTP to HTTPS and staples OCSP where the CA offers it. It needs DNS pointing at the box and ports 80 and 443 open. Hostnames like localhost get a cert from Caddy's own local CA (caddy trust installs it).

mkcert for local dev

brew install mkcert nss     # nss: Firefox trust store
mkcert -install             # create and trust a local CA
mkcert localhost 127.0.0.1 ::1 app.test
# → localhost+3.pem, localhost+3-key.pem
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
dev-https.ts
Bun.serve({
  port: 3443,
  tls: {
    cert: Bun.file("localhost+3.pem"),
    key: Bun.file("localhost+3-key.pem"),
  },
  fetch: () => new Response("https on localhost"),
});

Next.js: next dev --experimental-https generates and trusts a cert for you. Never share rootCA-key.pem: anyone holding it can mint certs your machine trusts.

Private CA & mTLS

Mutual TLS: the server also demands a client certificate signed by a CA it trusts. Use a private CA; public CAs are dropping the clientAuth usage.

openssl req -x509 -newkey ec \
  -pkeyopt ec_paramgen_curve:P-256 -noenc -days 3650 \
  -keyout ca.key -out ca.crt -subj "/CN=Internal mTLS CA"
openssl req -new -newkey ec \
  -pkeyopt ec_paramgen_curve:P-256 -noenc \
  -keyout client.key -out client.csr -subj "/CN=svc-a"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
  -days 90 -out client.crt \
  -extfile <(printf "extendedKeyUsage=clientAuth")
curl --cert client.crt --key client.key \
  --cacert ca.crt https://api.internal/
mtls-server.ts
Bun.serve({
  port: 8443,
  tls: {
    cert: Bun.file("server.crt"),
    key: Bun.file("server.key"),
    ca: Bun.file("ca.crt"), // CA that signs client certs
    requestCert: true,
    rejectUnauthorized: true,
  },
  fetch: () => new Response("hello, trusted client"),
});

nginx equivalent: ssl_client_certificate /etc/nginx/client-ca.crt; plus ssl_verify_client on;. For more than a handful of services, run step-ca or a service mesh.

GPG

Still used for signing apt/rpm repositories and release tarballs, encrypted email, and tools like pass or sops. For commit signing SSH keys are simpler; for file encryption age (age -R ~/.ssh/id_ed25519.pub) is simpler.

CommandDoes
gpg --full-generate-keynew key pair (choose ECC / Curve25519)
gpg --list-secret-keys --keyid-format=longyour keys and IDs
gpg --armor --export KEYIDpublic key to share
gpg --import key.ascimport someone's key
gpg -e -r alice@x.dev fileencrypt → file.gpg
gpg -d file.gpgdecrypt
gpg --detach-sign -a filesignature → file.asc
gpg --verify file.asc filecheck a download
gpg --dearmorASCII key → binary keyring (for apt Signed-By)

Common errors

MessageCauseFix
Permissions 0644 for 'id_ed25519' are too openprivate key readable by otherschmod 600 ~/.ssh/id_ed25519
Authentication refused: bad ownership or modes (server log)~ or ~/.ssh writable by otherschmod go-w ~, chmod 700 ~/.ssh, chmod 600 ~/.ssh/authorized_keys
REMOTE HOST IDENTIFICATION HAS CHANGED!host rebuilt, IP reused, or a MITMverify the new fingerprint out of band, then ssh-keygen -R host
Permission denied (publickey)wrong user/key, key not in authorized_keysssh -v host; ssh -G host; server: journalctl -u ssh
Too many authentication failuresagent offered many keys firstIdentitiesOnly yes with an explicit IdentityFile
no matching host key type found. Their offer: ssh-rsaancient server, SHA-1 onlyupgrade it; one-off: -o HostKeyAlgorithms=+ssh-rsa
agent refused operationagent locked, token not touched, or key permsunlock 1Password, touch the key, ssh-add -l
bind: Address already in use (tunnel)local port takenpick another port; lsof -i :5433
unable to get local issuer certificateserver didn't send intermediates, or CA not trustedserve fullchain.pem; trust the private CA / NODE_EXTRA_CA_CERTS
self-signed certificate in certificate chaincorporate TLS proxy or private CAadd that CA to the trust store; don't disable verification
certificate has expiredexpired leaf or intermediate, or wrong clockrenew; check timedatectl
ERR_TLS_CERT_ALTNAME_INVALIDhostname not in SANreissue with the name in SAN
key values mismatch (nginx)cert and key don't paircompare public keys (openssl table)
wrong version numberTLS spoken to a plain HTTP portcheck port and scheme

Recipes

New machine key setup

First hour on a new laptop: one key, stored in the agent, registered with GitHub.

ssh-keygen -t ed25519 -a 100 -C "zach@$(hostname -s)"
mkdir -p ~/.ssh/sockets && chmod 700 ~/.ssh ~/.ssh/sockets
# macOS: keep the passphrase in Keychain
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
# Linux: ssh-add ~/.ssh/id_ed25519
gh ssh-key add ~/.ssh/id_ed25519.pub \
  --type authentication --title "$(hostname -s)"
gh ssh-key add ~/.ssh/id_ed25519.pub \
  --type signing --title "$(hostname -s) signing"
ssh -T git@github.com
ssh-copy-id -i ~/.ssh/id_ed25519.pub zach@server

Jump host config

Reach private hosts through a bastion without agent forwarding.

~/.ssh/config
Host bastion
  HostName bastion.example.com
  User zach
  IdentityFile ~/.ssh/id_ed25519
 
Host 10.0.* *.internal
  User deploy
  ProxyJump bastion
  IdentityFile ~/.ssh/id_ed25519
 
# one-off without config:
#   ssh -J zach@bastion.example.com deploy@10.0.1.12
# scp and rsync hop the same way:
#   rsync -avz dist/ 10.0.1.12:/srv/app/

SSH tunnel to a remote Postgres

Connect local tools to a database that only listens on a private network. See Postgres.

# local 5433 → db.internal:5432, via the bastion
ssh -fN -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:5433:db.internal:5432 bastion
psql "postgres://app@127.0.0.1:5433/app"
DATABASE_URL=postgres://app:pw@127.0.0.1:5433/app \
  bun run migrate
# DB on the SSH host itself
ssh -N -L 5433:localhost:5432 db-host
# close the backgrounded tunnel
pkill -f 'L 127.0.0.1:5433'

Inspect a site's certificate chain

Debug "unable to get local issuer" or check which CA a site uses.

H=example.com
# every cert the server sends: s = subject, i = issuer
openssl s_client -connect "$H:443" -servername "$H" \
  -showcerts </dev/null 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|^ +i:'
# leaf details
openssl s_client -connect "$H:443" -servername "$H" \
  </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates \
    -ext subjectAltName
# protocol, cipher, key exchange, verification result
openssl s_client -connect "$H:443" -servername "$H" \
  -brief </dev/null

Self-signed cert with SAN

For an internal service or test box where mkcert isn't available.

SAN="DNS:dev.local,DNS:localhost,IP:127.0.0.1"
openssl req -x509 -newkey ec \
  -pkeyopt ec_paramgen_curve:P-256 -noenc -days 365 \
  -keyout dev.key -out dev.crt -subj "/CN=dev.local" \
  -addext "subjectAltName=$SAN"
openssl x509 -in dev.crt -noout -ext subjectAltName
# clients must trust dev.crt explicitly:
curl --cacert dev.crt https://dev.local:8443/

Check certificate expiry

Run from a timer or CI; exits non-zero if any host expires within WARN_DAYS or fails verification.

cert-expiry.ts
import { connect } from "node:tls";
 
const WARN = Number(process.env.WARN_DAYS ?? 21);
 
const days = (host: string) =>
  new Promise<number>((resolve, reject) => {
    const s = connect({ host, port: 443, servername: host });
    s.once("secureConnect", () => {
      const { valid_to } = s.getPeerCertificate();
      const ms = Date.parse(valid_to) - Date.now();
      resolve(Math.floor(ms / 86_400_000));
      s.end();
    });
    s.setTimeout(1e4, () => s.destroy(Error("timeout")));
    s.once("error", reject); // also fires on invalid chains
  });
 
let failed = false;
for (const host of process.argv.slice(2)) {
  const d = await days(host).catch((e: Error) => e.message);
  const ok = typeof d === "number" && d >= WARN;
  failed ||= !ok;
  console.log(`${ok ? "ok  " : "WARN"} ${host}: ${d}`);
}
process.exit(failed ? 1 : 0);

Run with bun cert-expiry.ts example.com api.example.com. For a local file: openssl x509 -in c.pem -noout -checkend $((21*86400)).

Sign git commits with SSH

Get the "Verified" badge without GPG.

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
# let git verify your own (and teammates') signatures
printf '%s %s\n' "$(git config user.email)" \
  "$(cat ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile \
  ~/.ssh/allowed_signers
git commit --allow-empty -m "test: signed commit"
git log --show-signature -1
# GitHub: gh ssh-key add ~/.ssh/id_ed25519.pub --type signing

References