288 lines
8.3 KiB
Go
288 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.wntrmute.dev/kyle/goutils/certlib"
|
|
)
|
|
|
|
// loadCertsFromFile attempts to parse certificates from a file that may be in
|
|
// PEM or DER/PKCS#7 format. Returns the parsed certificates or an error.
|
|
func loadCertsFromFile(path string) ([]*x509.Certificate, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try PEM first
|
|
if certs, err := certlib.ParseCertificatesPEM(data); err == nil {
|
|
return certs, nil
|
|
}
|
|
|
|
// Try DER/PKCS7/PKCS12 (with no password)
|
|
if certs, _, err := certlib.ParseCertificatesDER(data, ""); err == nil {
|
|
return certs, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
func makePoolFromFile(path string) (*x509.CertPool, error) {
|
|
// Try PEM via helper (it builds a pool)
|
|
if pool, err := certlib.LoadPEMCertPool(path); err == nil && pool != nil {
|
|
return pool, nil
|
|
}
|
|
|
|
// Fallback: read as DER(s), add to a new pool
|
|
certs, err := loadCertsFromFile(path)
|
|
if err != nil || len(certs) == 0 {
|
|
return nil, fmt.Errorf("failed to load CA certificates from %s", path)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
for _, c := range certs {
|
|
pool.AddCert(c)
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
//go:embed testdata/*.pem
|
|
var embeddedTestdata embed.FS
|
|
|
|
// loadCertsFromBytes attempts to parse certificates from bytes that may be in
|
|
// PEM or DER/PKCS#7 format.
|
|
func loadCertsFromBytes(data []byte) ([]*x509.Certificate, error) {
|
|
// Try PEM first
|
|
if certs, err := certlib.ParseCertificatesPEM(data); err == nil {
|
|
return certs, nil
|
|
}
|
|
// Try DER/PKCS7/PKCS12 (with no password)
|
|
if certs, _, err := certlib.ParseCertificatesDER(data, ""); err == nil {
|
|
return certs, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
func makePoolFromBytes(data []byte) (*x509.CertPool, error) {
|
|
certs, err := loadCertsFromBytes(data)
|
|
if err != nil || len(certs) == 0 {
|
|
return nil, fmt.Errorf("failed to load CA certificates from embedded bytes")
|
|
}
|
|
pool := x509.NewCertPool()
|
|
for _, c := range certs {
|
|
pool.AddCert(c)
|
|
}
|
|
return pool, nil
|
|
}
|
|
|
|
// isSelfSigned returns true if the given certificate is self-signed.
|
|
// It checks that the subject and issuer match and that the certificate's
|
|
// signature verifies against its own public key.
|
|
func isSelfSigned(cert *x509.Certificate) bool {
|
|
if cert == nil {
|
|
return false
|
|
}
|
|
// Quick check: subject and issuer match
|
|
if cert.Subject.String() != cert.Issuer.String() {
|
|
return false
|
|
}
|
|
// Cryptographic check: the certificate is signed by itself
|
|
if err := cert.CheckSignatureFrom(cert); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func verifyAgainstCA(caPool *x509.CertPool, path string) (ok bool, expiry string) {
|
|
certs, err := loadCertsFromFile(path)
|
|
if err != nil || len(certs) == 0 {
|
|
return false, ""
|
|
}
|
|
|
|
leaf := certs[0]
|
|
ints := x509.NewCertPool()
|
|
if len(certs) > 1 {
|
|
for _, ic := range certs[1:] {
|
|
ints.AddCert(ic)
|
|
}
|
|
}
|
|
|
|
opts := x509.VerifyOptions{
|
|
Roots: caPool,
|
|
Intermediates: ints,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := leaf.Verify(opts); err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
return true, leaf.NotAfter.Format("2006-01-02")
|
|
}
|
|
|
|
func verifyAgainstCABytes(caPool *x509.CertPool, certData []byte) (ok bool, expiry string) {
|
|
certs, err := loadCertsFromBytes(certData)
|
|
if err != nil || len(certs) == 0 {
|
|
return false, ""
|
|
}
|
|
|
|
leaf := certs[0]
|
|
ints := x509.NewCertPool()
|
|
if len(certs) > 1 {
|
|
for _, ic := range certs[1:] {
|
|
ints.AddCert(ic)
|
|
}
|
|
}
|
|
|
|
opts := x509.VerifyOptions{
|
|
Roots: caPool,
|
|
Intermediates: ints,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := leaf.Verify(opts); err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
return true, leaf.NotAfter.Format("2006-01-02")
|
|
}
|
|
|
|
// selftest runs built-in validation using embedded certificates.
|
|
func selftest() int {
|
|
type testCase struct {
|
|
name string
|
|
caFile string
|
|
certFile string
|
|
expectOK bool
|
|
}
|
|
|
|
cases := []testCase{
|
|
{name: "ISRG Root X1 validates LE E7", caFile: "testdata/isrg-root-x1.pem", certFile: "testdata/le-e7.pem", expectOK: true},
|
|
{name: "ISRG Root X1 does NOT validate Google WR2", caFile: "testdata/isrg-root-x1.pem", certFile: "testdata/goog-wr2.pem", expectOK: false},
|
|
{name: "GTS R1 validates Google WR2", caFile: "testdata/gts-r1.pem", certFile: "testdata/goog-wr2.pem", expectOK: true},
|
|
{name: "GTS R1 does NOT validate LE E7", caFile: "testdata/gts-r1.pem", certFile: "testdata/le-e7.pem", expectOK: false},
|
|
}
|
|
|
|
failures := 0
|
|
for _, tc := range cases {
|
|
caBytes, err := embeddedTestdata.ReadFile(tc.caFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", tc.caFile, err)
|
|
failures++
|
|
continue
|
|
}
|
|
certBytes, err := embeddedTestdata.ReadFile(tc.certFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", tc.certFile, err)
|
|
failures++
|
|
continue
|
|
}
|
|
pool, err := makePoolFromBytes(caBytes)
|
|
if err != nil || pool == nil {
|
|
fmt.Fprintf(os.Stderr, "selftest: failed to build CA pool for %s: %v\n", tc.caFile, err)
|
|
failures++
|
|
continue
|
|
}
|
|
ok, exp := verifyAgainstCABytes(pool, certBytes)
|
|
if ok != tc.expectOK {
|
|
fmt.Printf("%s: unexpected result: got %v, want %v\n", tc.name, ok, tc.expectOK)
|
|
failures++
|
|
} else {
|
|
if ok {
|
|
fmt.Printf("%s: OK (expires %s)\n", tc.name, exp)
|
|
} else {
|
|
fmt.Printf("%s: INVALID (as expected)\n", tc.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify that both embedded root CAs are detected as self-signed
|
|
roots := []string{"testdata/gts-r1.pem", "testdata/isrg-root-x1.pem"}
|
|
for _, root := range roots {
|
|
b, err := embeddedTestdata.ReadFile(root)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "selftest: failed to read embedded %s: %v\n", root, err)
|
|
failures++
|
|
continue
|
|
}
|
|
certs, err := loadCertsFromBytes(b)
|
|
if err != nil || len(certs) == 0 {
|
|
fmt.Fprintf(os.Stderr, "selftest: failed to parse cert(s) from %s: %v\n", root, err)
|
|
failures++
|
|
continue
|
|
}
|
|
leaf := certs[0]
|
|
if isSelfSigned(leaf) {
|
|
fmt.Printf("%s: SELF-SIGNED (as expected)\n", root)
|
|
} else {
|
|
fmt.Printf("%s: expected SELF-SIGNED, but was not detected as such\n", root)
|
|
failures++
|
|
}
|
|
}
|
|
|
|
if failures == 0 {
|
|
fmt.Println("selftest: PASS")
|
|
return 0
|
|
}
|
|
fmt.Fprintf(os.Stderr, "selftest: FAIL (%d failure(s))\n", failures)
|
|
return 1
|
|
}
|
|
|
|
func main() {
|
|
// Special selftest mode: single argument "selftest"
|
|
if len(os.Args) == 2 && os.Args[1] == "selftest" {
|
|
os.Exit(selftest())
|
|
}
|
|
|
|
if len(os.Args) < 3 {
|
|
prog := filepath.Base(os.Args[0])
|
|
fmt.Fprintf(os.Stderr, "Usage:\n %s ca.pem cert1.pem cert2.pem ...\n %s selftest\n", prog, prog)
|
|
os.Exit(2)
|
|
}
|
|
|
|
caPath := os.Args[1]
|
|
caPool, err := makePoolFromFile(caPath)
|
|
if err != nil || caPool == nil {
|
|
fmt.Fprintf(os.Stderr, "failed to load CA certificate(s): %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
for _, certPath := range os.Args[2:] {
|
|
ok, exp := verifyAgainstCA(caPool, certPath)
|
|
name := filepath.Base(certPath)
|
|
// Load the leaf once for self-signed detection and potential expiry fallback
|
|
var leaf *x509.Certificate
|
|
if certs, err := loadCertsFromFile(certPath); err == nil && len(certs) > 0 {
|
|
leaf = certs[0]
|
|
}
|
|
|
|
// If the certificate is self-signed, prefer the SELF-SIGNED label
|
|
if isSelfSigned(leaf) {
|
|
fmt.Printf("%s: SELF-SIGNED\n", name)
|
|
continue
|
|
}
|
|
|
|
if ok {
|
|
// Display with the requested format
|
|
// Example: file: OK (expires 2031-01-01)
|
|
// Ensure deterministic date formatting
|
|
// Note: no timezone displayed; date only as per example
|
|
// If exp ended up empty for some reason, recompute safely
|
|
if exp == "" {
|
|
if leaf != nil {
|
|
exp = leaf.NotAfter.Format("2006-01-02")
|
|
} else {
|
|
// fallback to the current date to avoid empty; though shouldn't happen
|
|
exp = time.Now().Format("2006-01-02")
|
|
}
|
|
}
|
|
fmt.Printf("%s: OK (expires %s)\n", name, exp)
|
|
} else {
|
|
fmt.Printf("%s: INVALID\n", name)
|
|
}
|
|
}
|
|
}
|