Compare commits

...

7 Commits

7 changed files with 254 additions and 344 deletions

View File

@@ -1,5 +1,22 @@
CHANGELOG
v1.14.7 - 2025-11-18
Changed:
- cmd/ca-signed: cleaned up code internally.
- lib: add base64 encoding to HexEncode.
- linter fixes.
v1.14.6 - 2025-11-18
Added:
- certlib: move tlskeypair functions into certlib.
v1.14.5 - 2025-11-18
Changed:
- certlib/verify: fix a nil-pointer dereference.
v1.14.4 - 2025-11-18
Added:

View File

@@ -4,6 +4,7 @@ import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"git.wntrmute.dev/kyle/goutils/certlib/certerr"
@@ -93,3 +94,18 @@ func LoadCertificates(path string) ([]*x509.Certificate, error) {
return ReadCertificates(in)
}
func PoolFromBytes(certBytes []byte) (*x509.CertPool, error) {
pool := x509.NewCertPool()
certs, err := ReadCertificates(certBytes)
if err != nil {
return nil, fmt.Errorf("failed to read certificates: %w", err)
}
for _, cert := range certs {
pool.AddCert(cert)
}
return pool, nil
}

135
certlib/keymatch.go Normal file
View File

@@ -0,0 +1,135 @@
package certlib
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
)
// LoadPrivateKey loads a private key from disk. It accepts both PEM and DER
// encodings and supports RSA and ECDSA keys. If the file contains a PEM block,
// the block type must be one of the recognised private key types.
func LoadPrivateKey(path string) (crypto.Signer, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, err
}
in = bytes.TrimSpace(in)
if p, _ := pem.Decode(in); p != nil {
if !validPEMs[p.Type] {
return nil, errors.New("invalid private key file type " + p.Type)
}
return ParsePrivateKeyPEM(in)
}
return ParsePrivateKeyDER(in)
}
var validPEMs = map[string]bool{
"PRIVATE KEY": true,
"RSA PRIVATE KEY": true,
"EC PRIVATE KEY": true,
}
const (
curveInvalid = iota // any invalid curve
curveRSA // indicates key is an RSA key, not an EC key
curveP256
curveP384
curveP521
)
func getECCurve(pub any) int {
switch pub := pub.(type) {
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return curveP256
case elliptic.P384():
return curveP384
case elliptic.P521():
return curveP521
default:
return curveInvalid
}
case *rsa.PublicKey:
return curveRSA
default:
return curveInvalid
}
}
// matchRSA compares an RSA public key from certificate against RSA public key from private key.
// It returns true on match.
func matchRSA(certPub *rsa.PublicKey, keyPub *rsa.PublicKey) bool {
return keyPub.N.Cmp(certPub.N) == 0 && keyPub.E == certPub.E
}
// matchECDSA compares ECDSA public keys for equality and compatible curve.
// It returns match=true when they are on the same curve and have the same X/Y.
// If curves mismatch, match is false.
func matchECDSA(certPub *ecdsa.PublicKey, keyPub *ecdsa.PublicKey) bool {
if getECCurve(certPub) != getECCurve(keyPub) {
return false
}
if keyPub.X.Cmp(certPub.X) != 0 {
return false
}
if keyPub.Y.Cmp(certPub.Y) != 0 {
return false
}
return true
}
// MatchKeys determines whether the certificate's public key matches the given private key.
// It returns true if they match; otherwise, it returns false and a human-friendly reason.
func MatchKeys(cert *x509.Certificate, priv crypto.Signer) (bool, string) {
switch keyPub := priv.Public().(type) {
case *rsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *rsa.PublicKey:
if matchRSA(certPub, keyPub) {
return true, ""
}
return false, "public keys don't match"
case *ecdsa.PublicKey:
return false, "RSA private key, EC public key"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
case *ecdsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
if matchECDSA(certPub, keyPub) {
return true, ""
}
// Determine a more precise reason
kc := getECCurve(keyPub)
cc := getECCurve(certPub)
if kc == curveInvalid {
return false, "invalid private key curve"
}
if cc == curveRSA {
return false, "private key is EC, certificate is RSA"
}
if kc != cc {
return false, "EC curves don't match"
}
return false, "public keys don't match"
case *rsa.PublicKey:
return false, "private key is EC, certificate is RSA"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
default:
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
}
}

View File

@@ -48,7 +48,9 @@ func prepareVerification(w io.Writer, target string, opts *Opts) (*verifyResult,
Config: lib.StrictBaselineTLSConfig(),
ForceIntermediates: false,
}
}
if opts.Config.RootCAs == nil {
roots, err = x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("couldn't load system cert pool: %w", err)

View File

@@ -1,157 +1,22 @@
package main
import (
"bytes"
"crypto/x509"
"embed"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/certlib/verify"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
// 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) {
var certs []*x509.Certificate
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if certs, err = certlib.ParseCertificatesPEM(data); err == nil {
return certs, nil
}
if certs, _, err = certlib.ParseCertificatesDER(data, ""); err == nil {
return certs, nil
}
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) {
certs, err := certlib.ParseCertificatesPEM(data)
if err == nil {
return certs, nil
}
certs, _, err = certlib.ParseCertificatesDER(data, "")
if err == nil {
return certs, nil
}
return nil, err
}
func makePoolFromBytes(data []byte) (*x509.CertPool, error) {
certs, err := loadCertsFromBytes(data)
if err != nil || len(certs) == 0 {
return nil, errors.New("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) (bool, 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) (bool, 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")
}
type testCase struct {
name string
caFile string
@@ -170,18 +35,25 @@ func (tc testCase) Run() error {
return fmt.Errorf("selftest: failed to read embedded %s: %w", tc.certFile, err)
}
pool, err := makePoolFromBytes(caBytes)
pool, err := certlib.PoolFromBytes(caBytes)
if err != nil || pool == nil {
return fmt.Errorf("selftest: failed to build CA pool for %s: %w", tc.caFile, err)
}
ok, exp := verifyAgainstCABytes(pool, certBytes)
cert, _, err := certlib.ReadCertificate(certBytes)
if err != nil {
return fmt.Errorf("selftest: failed to parse certificate from %s: %w", tc.certFile, err)
}
_, err = verify.CertWith(cert, pool, nil, false)
ok := err == nil
if ok != tc.expectOK {
return fmt.Errorf("%s: unexpected result: got %v, want %v", tc.name, ok, tc.expectOK)
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", tc.name, exp)
fmt.Printf("%s: OK (expires %s)\n", tc.name, cert.NotAfter.Format(lib.DateShortFormat))
}
fmt.Printf("%s: INVALID (as expected)\n", tc.name)
@@ -237,14 +109,16 @@ func selftest() int {
failures++
continue
}
certs, err := loadCertsFromBytes(b)
certs, err := certlib.ReadCertificates(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) {
if len(leaf.AuthorityKeyId) == 0 || bytes.Equal(leaf.AuthorityKeyId, leaf.SubjectKeyId) {
fmt.Printf("%s: SELF-SIGNED (as expected)\n", root)
} else {
fmt.Printf("%s: expected SELF-SIGNED, but was not detected as such\n", root)
@@ -260,66 +134,58 @@ func selftest() int {
return 1
}
// expiryString returns a YYYY-MM-DD date string to display for certificate
// expiry. If an explicit exp string is provided, it is used. Otherwise, if a
// leaf certificate is available, its NotAfter is formatted. As a last resort,
// it falls back to today's date (should not normally happen).
func expiryString(leaf *x509.Certificate, exp string) string {
if exp != "" {
return exp
}
if leaf != nil {
return leaf.NotAfter.Format("2006-01-02")
}
return time.Now().Format("2006-01-02")
}
// processCert verifies a single certificate file against the provided CA pool
// and prints the result in the required format, handling self-signed
// certificates specially.
func processCert(caPool *x509.CertPool, certPath string) {
ok, exp := verifyAgainstCA(caPool, certPath)
name := filepath.Base(certPath)
// Try to load the leaf cert for self-signed detection and expiry fallback
var leaf *x509.Certificate
if certs, err := loadCertsFromFile(certPath); err == nil && len(certs) > 0 {
leaf = certs[0]
}
// Prefer the SELF-SIGNED label if applicable
if isSelfSigned(leaf) {
fmt.Printf("%s: SELF-SIGNED\n", name)
return
}
if ok {
fmt.Printf("%s: OK (expires %s)\n", name, expiryString(leaf, exp))
return
}
fmt.Printf("%s: INVALID\n", name)
}
func main() {
// Special selftest mode: single argument "selftest"
if len(os.Args) == 2 && os.Args[1] == "selftest" {
var skipVerify, useStrict bool
lib.StrictTLSFlag(&useStrict)
flag.BoolVar(&skipVerify, "k", false, "don't verify certificates")
flag.Parse()
tcfg, err := lib.BaselineTLSConfig(skipVerify, useStrict)
die.If(err)
args := flag.Args()
if len(args) == 1 && args[0] == "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)
if len(args) < 2 {
fmt.Println("No certificates to check.")
os.Exit(1)
}
for _, certPath := range os.Args[2:] {
processCert(caPool, certPath)
caFile := args[0]
args = args[1:]
caCert, err := certlib.LoadCertificates(caFile)
die.If(err)
if len(caCert) != 1 {
die.With("only one CA certificate should be presented.")
}
roots := x509.NewCertPool()
roots.AddCert(caCert[0])
for _, arg := range args {
var cert *x509.Certificate
cert, err = lib.GetCertificate(arg, tcfg)
if err != nil {
lib.Warn(err, "while parsing certificate from %s", arg)
continue
}
if bytes.Equal(cert.AuthorityKeyId, caCert[0].AuthorityKeyId) {
fmt.Printf("%s: SELF-SIGNED\n", arg)
continue
}
if _, err = verify.CertWith(cert, roots, nil, false); err != nil {
fmt.Printf("%s: INVALID\n", arg)
} else {
fmt.Printf("%s: OK (expires %s)\n", arg, cert.NotAfter.Format(lib.DateShortFormat))
}
}
}

View File

@@ -1,14 +1,6 @@
package main
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"flag"
"fmt"
"os"
@@ -17,123 +9,7 @@ import (
"git.wntrmute.dev/kyle/goutils/die"
)
var validPEMs = map[string]bool{
"PRIVATE KEY": true,
"RSA PRIVATE KEY": true,
"EC PRIVATE KEY": true,
}
const (
curveInvalid = iota // any invalid curve
curveRSA // indicates key is an RSA key, not an EC key
curveP256
curveP384
curveP521
)
func getECCurve(pub any) int {
switch pub := pub.(type) {
case *ecdsa.PublicKey:
switch pub.Curve {
case elliptic.P256():
return curveP256
case elliptic.P384():
return curveP384
case elliptic.P521():
return curveP521
default:
return curveInvalid
}
case *rsa.PublicKey:
return curveRSA
default:
return curveInvalid
}
}
// matchRSA compares an RSA public key from certificate against RSA public key from private key.
// It returns true on match.
func matchRSA(certPub *rsa.PublicKey, keyPub *rsa.PublicKey) bool {
return keyPub.N.Cmp(certPub.N) == 0 && keyPub.E == certPub.E
}
// matchECDSA compares ECDSA public keys for equality and compatible curve.
// It returns match=true when they are on the same curve and have the same X/Y.
// If curves mismatch, match is false.
func matchECDSA(certPub *ecdsa.PublicKey, keyPub *ecdsa.PublicKey) bool {
if getECCurve(certPub) != getECCurve(keyPub) {
return false
}
if keyPub.X.Cmp(certPub.X) != 0 {
return false
}
if keyPub.Y.Cmp(certPub.Y) != 0 {
return false
}
return true
}
// matchKeys determines whether the certificate's public key matches the given private key.
// It returns true if they match; otherwise, it returns false and a human-friendly reason.
func matchKeys(cert *x509.Certificate, priv crypto.Signer) (bool, string) {
switch keyPub := priv.Public().(type) {
case *rsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *rsa.PublicKey:
if matchRSA(certPub, keyPub) {
return true, ""
}
return false, "public keys don't match"
case *ecdsa.PublicKey:
return false, "RSA private key, EC public key"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
case *ecdsa.PublicKey:
switch certPub := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
if matchECDSA(certPub, keyPub) {
return true, ""
}
// Determine a more precise reason
kc := getECCurve(keyPub)
cc := getECCurve(certPub)
if kc == curveInvalid {
return false, "invalid private key curve"
}
if cc == curveRSA {
return false, "private key is EC, certificate is RSA"
}
if kc != cc {
return false, "EC curves don't match"
}
return false, "public keys don't match"
case *rsa.PublicKey:
return false, "private key is EC, certificate is RSA"
default:
return false, fmt.Sprintf("unsupported certificate public key type: %T", cert.PublicKey)
}
default:
return false, fmt.Sprintf("unrecognised private key type: %T", priv.Public())
}
}
func loadKey(path string) (crypto.Signer, error) {
in, err := os.ReadFile(path)
if err != nil {
return nil, err
}
in = bytes.TrimSpace(in)
if p, _ := pem.Decode(in); p != nil {
if !validPEMs[p.Type] {
return nil, errors.New("invalid private key file type " + p.Type)
}
return certlib.ParsePrivateKeyPEM(in)
}
return certlib.ParsePrivateKeyDER(in)
}
// functionality refactored into certlib
func main() {
var keyFile, certFile string
@@ -141,23 +17,13 @@ func main() {
flag.StringVar(&certFile, "c", "", "TLS `certificate` file")
flag.Parse()
in, err := os.ReadFile(certFile)
cert, err := certlib.LoadCertificate(certFile)
die.If(err)
p, _ := pem.Decode(in)
if p != nil {
if p.Type != "CERTIFICATE" {
die.With("invalid certificate (type is %s)", p.Type)
}
in = p.Bytes
}
cert, err := x509.ParseCertificate(in)
priv, err := certlib.LoadPrivateKey(keyFile)
die.If(err)
priv, err := loadKey(keyFile)
die.If(err)
matched, reason := matchKeys(cert, priv)
matched, reason := certlib.MatchKeys(cert, priv)
if matched {
fmt.Println("Match.")
return

View File

@@ -1,6 +1,7 @@
package lib
import (
"encoding/base64"
"encoding/hex"
"fmt"
"os"
@@ -126,6 +127,8 @@ const (
HexEncodeUpperColon
// HexEncodeBytes prints the string as a sequence of []byte.
HexEncodeBytes
// HexEncodeBase64 prints the string as a base64-encoded string.
HexEncodeBase64
)
func (m HexEncodeMode) String() string {
@@ -140,6 +143,8 @@ func (m HexEncodeMode) String() string {
return "ucolon"
case HexEncodeBytes:
return "bytes"
case HexEncodeBase64:
return "base64"
default:
panic("invalid hex encode mode")
}
@@ -157,6 +162,8 @@ func ParseHexEncodeMode(s string) HexEncodeMode {
return HexEncodeUpperColon
case "bytes":
return HexEncodeBytes
case "base64":
return HexEncodeBase64
}
panic("invalid hex encode mode")
@@ -218,21 +225,22 @@ func bytesAsByteSliceString(buf []byte) string {
return sb.String()
}
// HexEncode encodes the given bytes as a hexadecimal string.
// HexEncode encodes the given bytes as a hexadecimal string. It
// also supports a few other binary-encoding formats as well.
func HexEncode(b []byte, mode HexEncodeMode) string {
str := hexEncode(b)
switch mode {
case HexEncodeLower:
return str
return hexEncode(b)
case HexEncodeUpper:
return strings.ToUpper(str)
return strings.ToUpper(hexEncode(b))
case HexEncodeLowerColon:
return hexColons(str)
return hexColons(hexEncode(b))
case HexEncodeUpperColon:
return strings.ToUpper(hexColons(str))
return strings.ToUpper(hexColons(hexEncode(b)))
case HexEncodeBytes:
return bytesAsByteSliceString(b)
case HexEncodeBase64:
return base64.StdEncoding.EncodeToString(b)
default:
panic("invalid hex encode mode")
}