Skip to content
Projects

kanywst/y509 has 42 GitHub stars and 2 forks. GitHot measured +2 stars in 24 hours. Last checked 2026-11-09 13:48 UTC. Last push 2026-11-02.

How these counts are measured

TUI for X.509 certificate chains: verifies trust, and catches the missing intermediates and bad ordering that break curl but not browsers

42 stars2 forks +2 in 24h

Measured star growth

in 24h
+2
in 7 days
Update delayed
in 15 days
Update delayed
in 1 month
Update delayed

Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.

README.md

Original

y509

Go Report Card License Built with Bubble Tea

A TUI for X.509 certificate chains. It checks whether a chain verifies, and separately whether it was served correctly: missing intermediates, redundant roots, wrong order. That second check explains "works in the browser, breaks in curl".

Built on the Charm v2 stack: Bubble Tea, Lip Gloss, Bubbles, huh.

y509 Demo

Install

# Homebrew (macOS)
brew install kanywst/tap/y509

# FreeBSD
pkg install y509

# Go 1.27+
go install github.com/kanywst/y509/cmd/y509@latest
# Windows
scoop bucket add kanywst https://github.com/kanywst/scoop-bucket
scoop install kanywst/y509

Releases also ship binaries for macOS, Linux and Windows, .deb and .rpm packages, checksums, cosign signatures and an SBOM.

  • Windows: without Scoop, unzip and put y509.exe on your PATH. Use Windows Terminal or any ANSI terminal. Completion: y509 completion powershell.
  • FreeBSD: maintained as the security/y509 port. If the binary package has not reached your repo yet: make -C /usr/ports/security/y509 install clean.

Usage

y509 cert-chain.pem                       # a file (PEM, DER or PKCS#7)
y509 example.com:443                      # a live server
y509 smtp.example.com:587 --starttls smtp # ...behind STARTTLS
cat chain.pem | y509                      # stdin
kubectl get secret tls -o json | y509     # a Kubernetes TLS secret

PKCS#7 (.p7b, .p7c), PKCS#12 (.p12, .pfx) and Kubernetes TLS secrets are detected by content, so pipes work too. A secret is read from tls.crt, then ca.crt.

PKCS#12 is tried without a password first. If one is needed, it comes from Y509_PKCS12_PASSWORD, --password-file, or a prompt. Never a flag, since flags show up in ps. Private keys are ignored.

Live servers

y509 example.com:443
y509 --connect 10.0.0.1:8443 --servername api.internal
y509 db.example.com:5432 --starttls postgres
y509 ldap.example.com:389 --starttls ldap
y509 db.example.com:3306 --starttls mysql

An existing file path is read as a file; anything else is an address. --connect forces an address. --starttls supports smtp, lmtp, imap, nntp, ftp, ldap, mysql and postgres (aliases: mariadb, postgresql).

The TUI handshake verifies nothing on purpose, so you can inspect a broken chain. Certificates are shown in the order the server sent them.

Comparing two chains

y509 diff before.pem after.pem
y509 diff node1.example.com:443 node2.example.com:443
Chain differences:
  - leaf.example.com
  + leaf.example.com
    Example Intermediate CA

The leaf was replaced:
  • serial: 8149... -> 9e2c...
  • not after: 2026-09-30 -> 2026-12-29
  • dns names: example.com -> example.com, www.example.com

Certificates are matched by SHA-256 of their DER, not by subject, since a renewal keeps the name. Exit 0 if identical, 1 if different, like diff(1). Useful for spotting a rotation, or two CDN nodes serving different chains.

Validating from a script

validate checks against the system trust store and exits non-zero on anything a TLS client would reject:

y509 validate chain.pem                        # 0 = trusted
y509 validate example.com:443                  # also checks the hostname
y509 validate chain.pem --roots internal-ca.pem
Outcome Exit Meaning
trusted 0 verifies against the trust anchors
self-anchored 1 links up, but its root is not trusted (an internal PKI, or a missing root)
broken 1 does not link up: expired, bad signature, missing issuer, wrong hostname

Multiple targets are all checked before exiting; the exit is non-zero if any failed. With --json, a single target gives the usual object and several give {"targets": [...]}.

y509 validate www.example.com:443 api.example.com:443 smtp.example.com:587

--policy fips also checks the verified chain, root included, against the FIPS 140-3 approved algorithms, as a client in FIPS-only mode (GODEBUG=fips140=only, OpenSSL's FIPS provider) enforces them: RSA or ECDSA over SHA-256, SHA-384 or SHA-512, Ed25519, ML-DSA or SLH-DSA; ECDSA keys on P-256, P-384 or P-521; RSA keys of at least 2048 bits, an even size, with an odd public exponent above 65536. A trusted chain that breaks it exits 1.

--policy pqc requires post-quantum algorithms throughout the same chain: ML-DSA (FIPS 204) or SLH-DSA (FIPS 205) signatures, and ML-DSA, SLH-DSA or ML-KEM (FIPS 203) keys. Composite ML-DSA fails it. Go cannot verify an SLH-DSA signature, so a chain signed with one is reported broken.

y509 validate example.com:443 --policy fips
y509 validate chain.pem --roots root.pem --policy pqc

How the chain was served

Browsers fetch a missing intermediate from the AIA URL; curl, Go and Java do not. So a chain can verify and still be broken for clients. y509 reports that from what the server actually sent:

$ 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 "YR2" was
    never sent, so a client that does not chase AIA (curl, Go, Java) cannot
    build a chain
    fetch from: http://yr2.i.lencr.org/

The chain verified (the macOS verifier fetched the intermediate) but is still misconfigured. y509 also reports redundant roots, wrong order, duplicates and unrelated certificates.

Machine-readable output

y509 <target> --json prints the chain without a trust verdict:

y509 chain.pem --json | jq '.chain[0].otherNames'

validate --json prints the full result to stdout and nothing else. Errors go to stderr, so the output always parses. Exit codes are unchanged.

y509 validate example.com:443 --json | jq .
{
  "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",
        "detail": "the chain stops at a certificate that is not a CA; its issuer \"YR2\" was never sent, so a client that does not chase AIA (curl, Go, Java) cannot build a chain",
        "fetchUrls": ["http://yr2.i.lencr.org/"]
      }
    ]
  },
  "chain": [{ "index": 0, "commonName": "*.example.com", "daysUntilExpiry": 43, "…": "…" }],
  "connection": {
    "tlsVersion": "TLS 1.3",
    "cipherSuite": "TLS_AES_128_GCM_SHA256",
    "ocspStapled": true,
    "ocspStaple": {
      "status": "good",
      "serialNumber": "03d8…",
      "producedAt": "2026-09-14T09:00:00Z",
      "thisUpdate": "2026-09-14T09:00:00Z",
      "nextUpdate": "2026-09-21T08:59:59Z",
      "expired": false,
      "verified": true
    }
  },
  "conformance": { "ok": true, "findings": [] },
  "revocation": { "ok": true, "findings": [] }
}
  • chain is in the order presented, not sorted.
  • level and problem are strings; findings is always an array, never null.
  • connection exists only for a live server, not a file or stdin.
  • ocspStaple is what the server stapled. Nothing is fetched. No staple is not a finding.
    • verified: false on its own means the issuer was not in the chain. With verifyError, the issuer was there and the response did not verify.
    • No nextUpdate means the responder gave none: do not cache it.
    • If the staple was unreadable, ocspStapleError replaces ocspStaple. Only one of the two is ever present.
  • revocation judges that staple, with its own ok so presentation.ok keeps its meaning. Present only alongside connection.
    • problem is one of revoked, stale staple, staple not yet valid, unknown status, staple signature invalid, unreadable staple.
    • A signature left unchecked because the issuer was not sent is not a finding here. That is missing issuer under presentation.
  • conformance checks each certificate against rules clients enforce on their own, with its own ok. Present for files too.
    • problem is one of weak signature (SHA-1 or MD5, except a genuine SHA-1 self-signature, which no client checks), weak key (RSA under 2048 bits), no SAN (a server certificate with a common name but no DNS, IP or URI SAN), precertificate (a CT precertificate was served), unhandled critical extension (a critical extension Go does not process), insufficient SCTs (below Chrome's CT policy).
    • insufficient SCTs counts only SCTs whose signature verifies against the log's bundled key, as Chrome does. It is judged by validate only, for a chain trusted through the system store that carries at least one SCT, embedded or sent in the TLS handshake. A certificate with none is not judged, because an enterprise root in the system store looks the same as a public one. Nor is one that falls short only by SCTs from logs the bundled list does not know, which could close the gap, or any chain once that list is more than 70 days old, which is when Chrome stops enforcing CT too. A scheduled workflow refreshes the list twice a month.
    • conformance.ctPolicy says how that went: met, not met, or not judged: <reason>, including why for a chain CT does not apply to, such as one trusted through --roots. conformance.ok is not affected by a check that was not judged, so a gate that must not pass an unjudged chain reads ctPolicy (the Action exposes it as ct-policy).
  • policy is present only with --policy, as {name, ok, findings}. problem is unapproved signature or unapproved key.
  • chain[].noExpiry is true for the RFC 5280 9999-12-31 sentinel, a certificate with no well-defined expiration. daysUntilExpiry and validityDays still carry the real arithmetic, so read noExpiry before printing them.
  • chain[].scts lists the embedded SCTs as {logId, log, operator, logState, timestamp}, with logs named from a bundled copy of Google's CT log list (make ct-logs refreshes it). Nothing is verified or fetched. log, operator and logState are absent for a log the list does not know. If the list is damaged, sctError says why and scts keeps what was read before the damage.
  • chain[].extensions lists every extension as {oid, name, critical}. name is absent for one y509 does not know.
  • chain[].idns lists the internationalized names among the common name and dnsNames as {ascii, unicode}. For a name that fails IDNA validation, only the spelling the certificate carries is present and error says why. Absent when there are none.

The exit code alone cannot tell self-anchored from broken, or say anything about how the chain was served. JSON can:

# 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'

# Fail on a SHA-1 signature, a short RSA key, or a CN-only certificate.
y509 validate example.com:443 --json | jq -e '.conformance.ok'

# Fail on anything a FIPS-mode client would reject.
y509 validate example.com:443 --policy fips --json | jq -e '.policy.ok'

# Fail on any signature or key that is not post-quantum.
y509 validate example.com:443 --policy pqc --json | jq -e '.policy.ok'

# Fail on a revoked or stale stapled OCSP response.
y509 validate example.com:443 --json | jq -e '.revocation.ok'

# Find anything still negotiating below TLS 1.2.
y509 validate example.com:443 --json | jq -e '.connection.tlsVersion | test("1\\.[23]$")'

Taking an inventory

inventory lists what you have, without verifying anything. It exits 0 for anything it could read:

y509 inventory www.example.com:443 api.example.com:443
y509 inventory chain.pem --csv > inventory.csv
TARGET                   SUBJECT           KEY        SIGNATURE            EXPIRES     DAYS
www.example.com:443      www.example.com   ECDSA 256  ECDSA-SHA256         2026-11-02  49
www.example.com:443      R11               RSA 2048   SHA256-RSA           2027-03-12  179

Good for PCI DSS 4.0.1 inventories or finding RSA keys ahead of a post-quantum migration. --json adds fingerprints and lifetimes.

GitHub Actions

Downloads a release binary, verifies its checksum, and fails the job on the findings you pick:

- uses: kanywst/y509@v1
  with:
    target: example.com:443
    fail-on: untrusted,mis-served,expiring
    expiry-days: 30

mis-served is the one other tools miss: a chain that verifies but breaks curl, Go and Java.

Input Default
target host, host:port, or a PEM/DER path in the workspace
version latest a release tag; pin it for a reproducible check
fail-on untrusted,mis-served any of untrusted, mis-served, expiring, revocation, conformance, policy, or none
expiry-days 30 threshold for expiring
starttls smtp, imap, ftp, ldap, mysql, postgres
servername SNI name, when it differs from the host dialled
roots PEM file of extra trust anchors, for an internal PKI
no-system-roots false trust only roots
policy fips or pqc, passed to --policy
summary true write a report to the job summary

revocation fails on a stapled OCSP response that is revoked, stale, dated in the future, unknown, badly signed or unreadable. No staple passes. conformance fails on a SHA-1 or MD5 signature, an RSA key under 2048 bits, a host name only in the common name, a served precertificate, a critical extension Go does not process, or too few SCTs for Chrome's CT policy.

Outputs: trust-level, trusted, presentation-ok, days-until-expiry, problems, revocation-ok, conformance-ok, ct-policy, policy-ok, and report (path to the full JSON).

Findings not in fail-on still show up as warnings, so fail-on: none makes a monitor:

on:
  schedule:
    - cron: "0 6 * * *"

jobs:
  certificates:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        host: [www.example.com, api.example.com, smtp.example.com:587]
    steps:
      - uses: kanywst/y509@v1
        with:
          target: ${{ matrix.host }}
          fail-on: untrusted,mis-served,expiring

Keybindings

Key Action
↑/k ↓/j Navigate list
←/h →/l Switch panes
tab Cycle detail tabs
/ Search
f Filter (expired, expiring, valid, self-signed)
v Validate certificate
r Redial the server (live connections only)
e Export certificate (filename + format form)
y Copy selected certificate as PEM (OSC52)
esc Clear filter / close popup
? Help
q Quit

Configuration

~/.y509.yaml. Catppuccin Mocha by default.

# Max "expiring soon" window in days (default 30). The actual window is the
# smaller of this and a third of the certificate's lifetime, so a 6-day
# certificate warns at 2 days left.
expiry_warning_days: 30

theme:
  text: "#cdd6f4"
  border: "#45475a"
  border_focus: "#89b4fa"
  background: "#1e1e2e"
  status_bar: "#181825"
  status_bar_text: "#cdd6f4"
  command_bar: "#313244"
  command_bar_text: "#cdd6f4"
  error: "#f38ba8"
  highlight: "#89b4fa"
  highlight_text: "#1e1e2e"
  highlight_dim: "#313244"
  status_valid: "#a6e3a1"
  status_warning: "#f9e2af"
  status_expired: "#f38ba8"
  title: "#89dceb"
  section_title: "#b4befe"
  detail_key: "#9399b2"
  list_row_alt: "#181825"

Development

make build       # Build with version info
make test        # Run tests
make lint        # Run golangci-lint
make vulncheck   # Run govulncheck

See ROADMAP.md for plans and non-goals, and CONTRIBUTING.md before sending a patch.

Verifying releases

Releases include Sigstore-signed checksums, a CycloneDX SBOM and SLSA build provenance.

Provenance:

gh attestation verify y509-<version>-<os>-<arch>.tar.gz -R kanywst/y509

Checksums (signed keyless via GitHub OIDC, bundled in y509-<version>-checksums.txt.sigstore.json):

TAG=v<version>
gh release download "$TAG" -R kanywst/y509 -p '*-checksums.txt*'

cosign verify-blob \
  --bundle "y509-${TAG#v}-checksums.txt.sigstore.json" \
  --certificate-identity-regexp 'https://github.com/kanywst/y509/.github/workflows/release.yml@refs/tags/' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "y509-${TAG#v}-checksums.txt"

sha256sum -c "y509-${TAG#v}-checksums.txt" --ignore-missing

v1.0.2 and earlier ship a .sig and .pem instead. Verify those with --signature and --certificate on cosign 2.x (3.x dropped both flags).

License

Apache License 2.0

Explore related projects