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.
TUI for X.509 certificate chains: verifies trust, and catches the missing intermediates and bad ordering that break curl but not browsers
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
Originaly509
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.

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.exeon yourPATH. Use Windows Terminal or any ANSI terminal. Completion:y509 completion powershell. - FreeBSD: maintained as the
security/y509port. 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": [] }
}
chainis in the order presented, not sorted.levelandproblemare strings;findingsis always an array, nevernull.connectionexists only for a live server, not a file or stdin.ocspStapleis what the server stapled. Nothing is fetched. No staple is not a finding.verified: falseon its own means the issuer was not in the chain. WithverifyError, the issuer was there and the response did not verify.- No
nextUpdatemeans the responder gave none: do not cache it. - If the staple was unreadable,
ocspStapleErrorreplacesocspStaple. Only one of the two is ever present.
revocationjudges that staple, with its ownoksopresentation.okkeeps its meaning. Present only alongsideconnection.problemis one ofrevoked,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 issuerunderpresentation.
conformancechecks each certificate against rules clients enforce on their own, with its ownok. Present for files too.problemis one ofweak 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 SCTscounts only SCTs whose signature verifies against the log's bundled key, as Chrome does. It is judged byvalidateonly, 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.ctPolicysays how that went:met,not met, ornot judged: <reason>, including why for a chain CT does not apply to, such as one trusted through--roots.conformance.okis not affected by a check that was not judged, so a gate that must not pass an unjudged chain readsctPolicy(the Action exposes it asct-policy).
policyis present only with--policy, as{name, ok, findings}.problemisunapproved signatureorunapproved key.chain[].noExpiryis true for the RFC 52809999-12-31sentinel, a certificate with no well-defined expiration.daysUntilExpiryandvalidityDaysstill carry the real arithmetic, so readnoExpirybefore printing them.chain[].sctslists the embedded SCTs as{logId, log, operator, logState, timestamp}, with logs named from a bundled copy of Google's CT log list (make ct-logsrefreshes it). Nothing is verified or fetched.log,operatorandlogStateare absent for a log the list does not know. If the list is damaged,sctErrorsays why andsctskeeps what was read before the damage.chain[].extensionslists every extension as{oid, name, critical}.nameis absent for one y509 does not know.chain[].idnslists the internationalized names among the common name anddnsNamesas{ascii, unicode}. For a name that fails IDNA validation, only the spelling the certificate carries is present anderrorsays 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
codecrafters-io/build-your-own-x
Master programming by recreating your favorite technologies from scratch.
561,398 starspublic-apis/public-apis
A collective list of free APIs
497,679 starsmorluto/rea
Reverse engineer anything with agents, from app behavior down to native binaries.
447,495 starsopenclaw/openclaw
The AI that really does things. Any OS. Any Platform. The lobster way. 🦞
394,261 stars