X.509 chain inspector · terminal-native

Read the chain the server actually sent.

y509 opens a certificate chain from a file, a live TLS handshake, or stdin — then answers the question an exit code can't carry: not just whether the chain verifies, but whether it was served correctly.

$brew install kanywst/tap/y509
$go install github.com/kanywst/y509/cmd/y509@latest
macOS · Linux · Windows·Go 1.26+
single static binary cosign + SBOM + SLSA no telemetry, no phone home
y509 — testdata/demo/certs.pem
$
██    ██ ███████  ██████   █████
 ██  ██  ██      ██    ██ ██   ██
  ████   ███████ ██    ██  █████
   ██         ██ ██    ██      ██
   ██    ███████  ██████   █████ 
🔐 Certificate Chain TUI Viewer
🔐 y509 5 certs valid.y509.demo
────────────
SUBJECTEXPIRES
 
 5 certs  ↑↓ nav←→ panetab tabs/ searchf filterv validatee exporty copyq quit? help

Live, not a recording — jk select a certificate, tab step through detail tabs, esc release focus

The thesis

Verifying a chain and serving it correctly are two different questions.

A server can present a chain your browser accepts and curl refuses. Browsers chase the AIA URL to fetch a missing intermediate; curl, Go and Java do not. y509 answers both questions separately, and reports the second one from what was actually on the wire.

The chain below verified — on macOS the platform verifier fetched the missing intermediate over the network — and it is still misconfigured. That gap is the whole point: the check is structural, so it cannot be papered over.

real output · incomplete-chain.badssl.com
$ y509 validate incomplete-chain.badssl.com:443
✅ Certificate chain is valid.
Trust anchor: ISRG Root X1

Chain as presented:
  • missing issuer: *.badssl.com
    the chain stops at a certificate that is not a CA;
    its issuer "R13" was never sent, so a client that
    does not chase AIA (curl, Go, Java) cannot build
    a chain
    fetch from: http://r13.i.lencr.org/
TRUST ✅ verifies PRESENTATION ✖ misconfigured exit 0
Question one

Does it verify?

Checked against the system trust store, or your own roots with --roots. Hostname included when you point it at a live server.

✅ trusted · anchor: ISRG Root X1
Question two

Was it served correctly?

Missing intermediate, redundant root, wrong order, duplicates, strangers in the bundle — structural findings that verification papers over.

✖ missing issuer · *.badssl.com

Where the chain comes from

Four ways in. One reader.

An argument naming an existing file is always read as a file; anything else is treated as an address. Pass --connect to force it. The handshake deliberately verifies nothing — a chain that fails to verify is usually the reason you came.

Certificates come back in the order the server sent them, which is not necessarily a valid chain. A server shipping its root, or omitting an intermediate, is the classic "works in the browser, breaks in curl" bug.

reading a chain
# a file (PEM or DER)
$ y509 cert-chain.pem

# a live server
$ y509 example.com:443

# ...behind STARTTLS (smtp, imap, postgres)
$ y509 smtp.example.com:587 --starttls smtp

# an internal host with a different SNI
$ y509 --connect 10.0.0.1:8443 --servername api.internal

# stdin
$ cat chain.pem | y509
tui

Two panes, five tabs

The chain on the left with live expiry bars, the selected certificate on the right.

  • Subject · Issuer · Validity · SANs · Misc
  • search and filter
  • chain position table
net

Live handshake

TLS straight to the port, or through a STARTTLS upgrade, with SNI you control.

  • --connect · --servername
  • --starttls smtp / imap / postgres
  • verifies nothing on purpose
audit

Presentation findings

What the server sent, judged as a bundle rather than as a proof.

  • missing issuer
  • redundant root
  • out of order · duplicates · strangers
json

Machine-readable

A deliberate translation layer, not struct tags. Every value crossing it is a string, a timestamp or a bool.

  • --json owns stdout
  • errors go to stderr
  • slices marshal as [], never null
io

Export and copy

Pull a single certificate out of a bundle without reaching for openssl.

  • export to PEM / DER / text
  • yank as PEM over OSC52
  • works over SSH
theme

Yours to recolour

Catppuccin Mocha by default. Every colour on screen comes from one config file.

  • ~/.y509.yaml
  • expiry_warning_days
  • 19 theme keys

In a pipeline

Gate the build on the finding, not the prose.

validate verifies against the system trust store and exits non-zero on anything a TLS client would reject, so it can gate CI on its own. But the exit code collapses two different failures into one number.

OutcomeExitMeaning
trusted0verifies against the trust anchors
self-anchored1links up, but its root is not trusted — an internal PKI, or a missing root
broken1does not link up: expired, bad signature, missing issuer, wrong hostname
y509 validate example.com:443 --json
{
  "host": "example.com",
  "trust": {
    "level": "self-anchored",
    "trusted": false,
    "anchor": "Internal Root CA",
    "error": "x509: certificate signed by unknown authority"
  },
  "presentation": {
    "ok": false,
    "findings": [
      {
        "problem": "missing issuer",
        "subject": "*.example.com",
        "fetchUrls": ["http://r13.i.lencr.org/"]
      }
    ]
  },
  "chain": [{ "index": 0, "daysUntilExpiry": 43, "…": "…" }]
}
two checks worth wiring up
# Fail the build on a chain that verifies but is mis-served.
$ y509 validate example.com:443 --json | jq -e '.presentation.ok'

# Warn 30 days out, without parsing prose.
$ y509 validate example.com:443 --json | jq '.chain[0].daysUntilExpiry < 30'

--json exists because that number cannot carry the answer. A script reading only the exit code cannot tell an internal PKI from a chain that does not link up — and it learns nothing at all about how the chain was served, which is the finding you most likely came for.

chain is in the order the certificates were presented, not sorted, because sorting is what destroys the evidence presentation reports on.

Under your fingers

Every binding, one source.

The ? overlay is generated from the same key map the app dispatches on, so the help can never drift from the behaviour.

kjMove through the chain
hFocus the list
lFocus the details
tabCycle detail tabs
/Search by common name
fFilter: expired, expiring, valid, self-signed
vValidate this certificate
eExport — filename and format
yCopy as PEM over OSC52
escClear the filter, close the popup
?Help
q^cQuit

Get it

One binary, signed.

Homebrew

macOS and Linux, from the tap.

$brew install kanywst/tap/y509

Go

Straight from source, Go 1.26 or newer.

$go install github.com/kanywst/y509/cmd/y509@latest

Release archives

Every release attaches binaries for macOS and Linux, plus .deb and .rpm packages, with checksums, cosign signatures and a CycloneDX SBOM.

Releases ↗

Verify what you downloaded

Archives carry SLSA build provenance. Check it before you run it.

$gh attestation verify y509-*.tar.gz -R kanywst/y509

Configuration

~/.y509.yaml

Catppuccin Mocha ships as the default. Lower expiry_warning_days as CA/Browser Forum maximum lifetimes shrink — 200 days in 2026.

~/.y509.yaml
expiry_warning_days: 30

theme:
  text: "#cdd6f4"
  border_focus: "#89b4fa"
  status_valid: "#a6e3a1"
  status_warning: "#f9e2af"
  status_expired: "#f38ba8"
  # ...14 more keys

Point it at a host.
See what it really sends.

Built on the Charm v2 stack — Bubble Tea, Lip Gloss, Bubbles and huh. Apache 2.0.