diff --git a/certlib/dump/dump.go b/certlib/dump/dump.go index 2442424..b82076d 100644 --- a/certlib/dump/dump.go +++ b/certlib/dump/dump.go @@ -165,7 +165,7 @@ func certPublic(cert *x509.Certificate) string { } } -func displayName(name pkix.Name) string { +func DisplayName(name pkix.Name) string { var ns []string if name.CommonName != "" { @@ -270,8 +270,8 @@ func DisplayCert(w io.Writer, cert *x509.Certificate) { if showHash { fmt.Fprintln(w, wrap(fmt.Sprintf("SHA256: %x", sha256.Sum256(cert.Raw)), 0)) } - fmt.Fprintln(w, wrap("Subject: "+displayName(cert.Subject), 0)) - fmt.Fprintln(w, wrap("Issuer: "+displayName(cert.Issuer), 0)) + fmt.Fprintln(w, wrap("Subject: "+DisplayName(cert.Subject), 0)) + fmt.Fprintln(w, wrap("Issuer: "+DisplayName(cert.Issuer), 0)) fmt.Fprintf(w, "\tSignature algorithm: %s / %s\n", sigAlgoPK(cert.SignatureAlgorithm), sigAlgoHash(cert.SignatureAlgorithm)) fmt.Fprintln(w, "Details:") diff --git a/certlib/ski/ski.go b/certlib/ski/ski.go new file mode 100644 index 0000000..ce5809c --- /dev/null +++ b/certlib/ski/ski.go @@ -0,0 +1,157 @@ +package ski + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/sha1" // #nosec G505 this is the standard + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "os" + + "git.wntrmute.dev/kyle/goutils/certlib" + "git.wntrmute.dev/kyle/goutils/die" + "git.wntrmute.dev/kyle/goutils/lib" +) + +const ( + keyTypeRSA = "RSA" + keyTypeECDSA = "ECDSA" + keyTypeEd25519 = "Ed25519" +) + +type subjectPublicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString +} + +type KeyInfo struct { + PublicKey []byte + KeyType string + FileType string +} + +func (k *KeyInfo) String() string { + return fmt.Sprintf("%s (%s)", lib.HexEncode(k.PublicKey, lib.HexEncodeLowerColon), k.KeyType) +} + +func (k *KeyInfo) SKI(displayMode lib.HexEncodeMode) (string, error) { + var subPKI subjectPublicKeyInfo + + _, err := asn1.Unmarshal(k.PublicKey, &subPKI) + if err != nil { + return "", fmt.Errorf("serializing SKI: %w", err) + } + + pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) // #nosec G401 this is the standard + pubHashString := lib.HexEncode(pubHash[:], displayMode) + + return pubHashString, nil +} + +// ParsePEM parses a PEM file and returns the public key and its type. +func ParsePEM(path string) (*KeyInfo, error) { + material := &KeyInfo{} + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("parsing X.509 material %s: %w", path, err) + } + + data = bytes.TrimSpace(data) + p, rest := pem.Decode(data) + if len(rest) > 0 { + lib.Warnx("trailing data in PEM file") + } + + if p == nil { + return nil, fmt.Errorf("no PEM data in %s", path) + } + + data = p.Bytes + + switch p.Type { + case "PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY": + material.PublicKey, material.KeyType = parseKey(data) + material.FileType = "private key" + case "CERTIFICATE": + material.PublicKey, material.KeyType = parseCertificate(data) + material.FileType = "certificate" + case "CERTIFICATE REQUEST": + material.PublicKey, material.KeyType = parseCSR(data) + material.FileType = "certificate request" + default: + return nil, fmt.Errorf("unknown PEM type %s", p.Type) + } + + return material, nil +} + +func parseKey(data []byte) ([]byte, string) { + priv, err := certlib.ParsePrivateKeyDER(data) + if err != nil { + die.If(err) + } + + var kt string + switch priv.Public().(type) { + case *rsa.PublicKey: + kt = keyTypeRSA + case *ecdsa.PublicKey: + kt = keyTypeECDSA + default: + die.With("unknown private key type %T", priv) + } + + public, err := x509.MarshalPKIXPublicKey(priv.Public()) + die.If(err) + + return public, kt +} + +func parseCertificate(data []byte) ([]byte, string) { + cert, err := x509.ParseCertificate(data) + die.If(err) + + pub := cert.PublicKey + var kt string + switch pub.(type) { + case *rsa.PublicKey: + kt = keyTypeRSA + case *ecdsa.PublicKey: + kt = keyTypeECDSA + case *ed25519.PublicKey: + kt = keyTypeEd25519 + default: + die.With("unknown public key type %T", pub) + } + + public, err := x509.MarshalPKIXPublicKey(pub) + die.If(err) + return public, kt +} + +func parseCSR(data []byte) ([]byte, string) { + // Use certlib to support both PEM and DER and to centralize validation. + csr, _, err := certlib.ParseCSR(data) + die.If(err) + + pub := csr.PublicKey + var kt string + switch pub.(type) { + case *rsa.PublicKey: + kt = keyTypeRSA + case *ecdsa.PublicKey: + kt = keyTypeECDSA + default: + die.With("unknown public key type %T", pub) + } + + public, err := x509.MarshalPKIXPublicKey(pub) + die.If(err) + return public, kt +} diff --git a/certlib/verify/check.go b/certlib/verify/check.go new file mode 100644 index 0000000..c90c7bf --- /dev/null +++ b/certlib/verify/check.go @@ -0,0 +1,49 @@ +package verify + +import ( + "crypto/x509" + "fmt" + "time" + + "git.wntrmute.dev/kyle/goutils/certlib/dump" +) + +const DefaultLeeway = 2160 * time.Hour // three months + +type CertCheck struct { + Cert *x509.Certificate + leeway time.Duration +} + +func NewCertCheck(cert *x509.Certificate, leeway time.Duration) *CertCheck { + return &CertCheck{ + Cert: cert, + leeway: leeway, + } +} + +func (c CertCheck) Expiry() time.Duration { + return time.Until(c.Cert.NotAfter) +} + +func (c CertCheck) IsExpiring(leeway time.Duration) bool { + return c.Expiry() < leeway +} + +// Err returns nil if the certificate is not expiring within the leeway period. +func (c CertCheck) Err() error { + if !c.IsExpiring(c.leeway) { + return nil + } + + return fmt.Errorf("%s expires in %s", dump.DisplayName(c.Cert.Subject), c.Expiry()) +} + +func (c CertCheck) Name() string { + return fmt.Sprintf("%s/SN=%s", dump.DisplayName(c.Cert.Subject), + c.Cert.SerialNumber) +} + +func (c CertCheck) String() string { + return fmt.Sprintf("%s expires on %s (in %s)\n", c.Name(), c.Cert.NotAfter, c.Expiry()) +} diff --git a/certlib/verify/verify.go b/certlib/verify/verify.go new file mode 100644 index 0000000..39cab89 --- /dev/null +++ b/certlib/verify/verify.go @@ -0,0 +1,141 @@ +package verify + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + + "git.wntrmute.dev/kyle/goutils/certlib/revoke" + "git.wntrmute.dev/kyle/goutils/lib" +) + +func bundleIntermediates(w io.Writer, chain []*x509.Certificate, pool *x509.CertPool, verbose bool) *x509.CertPool { + for _, intermediate := range chain[1:] { + if verbose { + fmt.Fprintf(w, "[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId) + } + pool.AddCert(intermediate) + } + + return pool +} + +type Opts struct { + Verbose bool + Config *tls.Config + Intermediates *x509.CertPool + ForceIntermediates bool + CheckRevocation bool + KeyUsages []x509.ExtKeyUsage +} + +type verifyResult struct { + chain []*x509.Certificate + roots *x509.CertPool + ints *x509.CertPool +} + +func prepareVerification(w io.Writer, target string, opts *Opts) (*verifyResult, error) { + var ( + roots, ints *x509.CertPool + err error + ) + + if opts == nil { + opts = &Opts{ + Config: lib.StrictBaselineTLSConfig(), + ForceIntermediates: false, + } + + roots, err = x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("couldn't load system cert pool: %w", err) + } + + opts.Config.RootCAs = roots + } + + if opts.Intermediates == nil { + ints = x509.NewCertPool() + } else { + ints = opts.Intermediates.Clone() + } + + roots = opts.Config.RootCAs.Clone() + + chain, err := lib.GetCertificateChain(target, opts.Config) + if err != nil { + return nil, fmt.Errorf("fetching certificate chain: %w", err) + } + + if opts.Verbose { + fmt.Fprintf(w, "[+] %s has %d certificates\n", target, len(chain)) + } + + if len(chain) > 1 && opts.ForceIntermediates { + ints = bundleIntermediates(w, chain, ints, opts.Verbose) + } + + return &verifyResult{ + chain: chain, + roots: roots, + ints: ints, + }, nil +} + +// Chain fetches the certificate chain for a target and verifies it. +func Chain(w io.Writer, target string, opts *Opts) ([]*x509.Certificate, error) { + result, err := prepareVerification(w, target, opts) + if err != nil { + return nil, fmt.Errorf("certificate verification failed: %w", err) + } + + chains, err := CertWith(result.chain[0], result.roots, result.ints, opts.CheckRevocation, opts.KeyUsages...) + if err != nil { + return nil, fmt.Errorf("certificate verification failed: %w", err) + } + + return chains, nil +} + +// CertWith verifies a certificate against a set of roots and intermediates. +func CertWith( + cert *x509.Certificate, + roots, ints *x509.CertPool, + checkRevocation bool, + keyUses ...x509.ExtKeyUsage, +) ([]*x509.Certificate, error) { + if len(keyUses) == 0 { + keyUses = []x509.ExtKeyUsage{x509.ExtKeyUsageAny} + } + + opts := x509.VerifyOptions{ + Intermediates: ints, + Roots: roots, + KeyUsages: keyUses, + } + + chains, err := cert.Verify(opts) + if err != nil { + return nil, err + } + + if checkRevocation { + revoked, ok := revoke.VerifyCertificate(cert) + if !ok { + return nil, errors.New("failed to check certificate revocation status") + } + + if revoked { + return nil, errors.New("certificate is revoked") + } + } + + if len(chains) == 0 { + return nil, errors.New("no valid certificate chain found") + } + + return chains[0], nil +} diff --git a/cmd/certexpiry/main.go b/cmd/certexpiry/main.go index ae5633f..3496616 100644 --- a/cmd/certexpiry/main.go +++ b/cmd/certexpiry/main.go @@ -2,81 +2,22 @@ package main import ( "crypto/x509" - "crypto/x509/pkix" "flag" "fmt" - "os" - "strings" - "time" + "git.wntrmute.dev/kyle/goutils/certlib/verify" "git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/lib" ) -var warnOnly bool -var leeway = 2160 * time.Hour // three months - -func displayName(name pkix.Name) string { - var ns []string - - if name.CommonName != "" { - ns = append(ns, name.CommonName) - } - - for i := range name.Country { - ns = append(ns, fmt.Sprintf("C=%s", name.Country[i])) - } - - for i := range name.Organization { - ns = append(ns, fmt.Sprintf("O=%s", name.Organization[i])) - } - - for i := range name.OrganizationalUnit { - ns = append(ns, fmt.Sprintf("OU=%s", name.OrganizationalUnit[i])) - } - - for i := range name.Locality { - ns = append(ns, fmt.Sprintf("L=%s", name.Locality[i])) - } - - for i := range name.Province { - ns = append(ns, fmt.Sprintf("ST=%s", name.Province[i])) - } - - if len(ns) > 0 { - return "/" + strings.Join(ns, "/") - } - - die.With("no subject information in root") - return "" -} - -func expires(cert *x509.Certificate) time.Duration { - return time.Until(cert.NotAfter) -} - -func inDanger(cert *x509.Certificate) bool { - return expires(cert) < leeway -} - -func checkCert(cert *x509.Certificate) { - warn := inDanger(cert) - name := displayName(cert.Subject) - name = fmt.Sprintf("%s/SN=%s", name, cert.SerialNumber) - - expiry := expires(cert) - if warnOnly { - if warn { - fmt.Fprintf(os.Stderr, "%s expires on %s (in %s)\n", name, cert.NotAfter, expiry) - } - } else { - fmt.Printf("%s expires on %s (in %s)\n", name, cert.NotAfter, expiry) - } -} - func main() { - var skipVerify bool - var strictTLS bool + var ( + skipVerify bool + strictTLS bool + leeway = verify.DefaultLeeway + warnOnly bool + ) + lib.StrictTLSFlag(&strictTLS) flag.BoolVar(&skipVerify, "k", false, "skip server verification") // #nosec G402 @@ -97,7 +38,16 @@ func main() { } for _, cert := range certs { - checkCert(cert) + check := verify.NewCertCheck(cert, leeway) + + if warnOnly { + if err = check.Err(); err != nil { + lib.Warn(err, "certificate is expiring") + } + } else { + fmt.Printf("%s expires on %s (in %s)\n", check.Name(), + cert.NotAfter, check.Expiry()) + } } } } diff --git a/cmd/certverify/main.go b/cmd/certverify/main.go index 6c58fdf..fc370b8 100644 --- a/cmd/certverify/main.go +++ b/cmd/certverify/main.go @@ -5,29 +5,13 @@ import ( "flag" "fmt" "os" - "time" "git.wntrmute.dev/kyle/goutils/certlib" - "git.wntrmute.dev/kyle/goutils/certlib/revoke" + "git.wntrmute.dev/kyle/goutils/certlib/verify" + "git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/lib" ) -func printRevocation(cert *x509.Certificate) { - remaining := time.Until(cert.NotAfter) - fmt.Printf("certificate expires in %s.\n", lib.Duration(remaining)) - - revoked, ok := revoke.VerifyCertificate(cert) - if !ok { - fmt.Fprintf(os.Stderr, "[!] the revocation check failed (failed to determine whether certificate\nwas revoked)") - return - } - - if revoked { - fmt.Fprintf(os.Stderr, "[!] the certificate has been revoked\n") - return - } -} - type appConfig struct { caFile, intFile string forceIntermediateBundle bool @@ -46,107 +30,64 @@ func parseFlags() appConfig { flag.BoolVar(&cfg.verbose, "v", false, "verbose") lib.StrictTLSFlag(&cfg.strictTLS) flag.Parse() + + if flag.NArg() == 0 { + die.With("usage: certverify targets...") + } + return cfg } -func loadRoots(caFile string, verbose bool) (*x509.CertPool, error) { - if caFile == "" { - return x509.SystemCertPool() - } - - if verbose { - fmt.Println("[+] loading root certificates from", caFile) - } - return certlib.LoadPEMCertPool(caFile) -} - -func loadIntermediates(intFile string, verbose bool) (*x509.CertPool, error) { - if intFile == "" { - return x509.NewCertPool(), nil - } - if verbose { - fmt.Println("[+] loading intermediate certificates from", intFile) - } - // Note: use intFile here (previously used caFile mistakenly) - return certlib.LoadPEMCertPool(intFile) -} - -func addBundledIntermediates(chain []*x509.Certificate, pool *x509.CertPool, verbose bool) { - for _, intermediate := range chain[1:] { - if verbose { - fmt.Printf("[+] adding intermediate with SKI %x\n", intermediate.SubjectKeyId) - } - pool.AddCert(intermediate) - } -} - -func verifyCert(cert *x509.Certificate, roots, ints *x509.CertPool) error { - opts := x509.VerifyOptions{ - Intermediates: ints, - Roots: roots, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, - } - _, err := cert.Verify(opts) - return err -} - -func run(cfg appConfig) error { - roots, err := loadRoots(cfg.caFile, cfg.verbose) - if err != nil { - return err - } - - ints, err := loadIntermediates(cfg.intFile, cfg.verbose) - if err != nil { - return err - } - - if flag.NArg() != 1 { - fmt.Fprintf(os.Stderr, "Usage: %s [-ca bundle] [-i bundle] cert", lib.ProgName()) - } - - combinedPool, err := certlib.LoadFullCertPool(cfg.caFile, cfg.intFile) - if err != nil { - return fmt.Errorf("failed to build combined pool: %w", err) - } - - tlsCfg, err := lib.BaselineTLSConfig(cfg.skipVerify, cfg.strictTLS) - if err != nil { - return err - } - tlsCfg.RootCAs = combinedPool - - chain, err := lib.GetCertificateChain(flag.Arg(0), tlsCfg) - if err != nil { - return err - } - if cfg.verbose { - fmt.Printf("[+] %s has %d certificates\n", flag.Arg(0), len(chain)) - } - - cert := chain[0] - if len(chain) > 1 && !cfg.forceIntermediateBundle { - addBundledIntermediates(chain, ints, cfg.verbose) - } - - if err = verifyCert(cert, roots, ints); err != nil { - return fmt.Errorf("certificate verification failed: %w", err) - } - - if cfg.verbose { - fmt.Println("OK") - } - - if cfg.revexp { - printRevocation(cert) - } - return nil -} - func main() { + var ( + roots, ints *x509.CertPool + err error + failed bool + ) + cfg := parseFlags() - if err := run(cfg); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) + + opts := &verify.Opts{ + CheckRevocation: cfg.revexp, + ForceIntermediates: cfg.forceIntermediateBundle, + Verbose: cfg.verbose, + } + + if cfg.caFile != "" { + if cfg.verbose { + fmt.Printf("loading CA certificates from %s\n", cfg.caFile) + } + + roots, err = certlib.LoadPEMCertPool(cfg.caFile) + die.If(err) + } + + if cfg.intFile != "" { + if cfg.verbose { + fmt.Printf("loading intermediate certificates from %s\n", cfg.intFile) + } + + ints, err = certlib.LoadPEMCertPool(cfg.intFile) + die.If(err) + } + + opts.Config, err = lib.BaselineTLSConfig(cfg.skipVerify, cfg.strictTLS) + die.If(err) + + opts.Config.RootCAs = roots + opts.Intermediates = ints + + for _, arg := range flag.Args() { + _, err = verify.Chain(os.Stdout, arg, opts) + if err != nil { + lib.Warn(err, "while verifying %s", arg) + failed = true + } else { + fmt.Printf("%s: OK\n", arg) + } + } + + if failed { os.Exit(1) } } diff --git a/cmd/ski/main.go b/cmd/ski/main.go index f956ac8..94d2cec 100644 --- a/cmd/ski/main.go +++ b/cmd/ski/main.go @@ -1,29 +1,19 @@ package main import ( - "bytes" - "crypto/ecdsa" - "crypto/rsa" - "crypto/sha1" // #nosec G505 - "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" - "encoding/pem" + + // #nosec G505 + "flag" "fmt" "io" "os" - "git.wntrmute.dev/kyle/goutils/certlib" + "git.wntrmute.dev/kyle/goutils/certlib/ski" "git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/lib" ) -const ( - keyTypeRSA = "RSA" - keyTypeECDSA = "ECDSA" -) - func usage(w io.Writer) { fmt.Fprintf(w, `ski: print subject key info for PEM-encoded files @@ -42,117 +32,6 @@ func init() { flag.Usage = func() { usage(os.Stderr) } } -func parse(path string) ([]byte, string, string) { - data, err := os.ReadFile(path) - die.If(err) - - data = bytes.TrimSpace(data) - p, rest := pem.Decode(data) - if len(rest) > 0 { - _, _ = lib.Warnx("trailing data in PEM file") - } - - if p == nil { - die.With("no PEM data found") - } - - data = p.Bytes - - var ( - public []byte - kt string - ft string - ) - - switch p.Type { - case "PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY": - public, kt = parseKey(data) - ft = "private key" - case "CERTIFICATE": - public, kt = parseCertificate(data) - ft = "certificate" - case "CERTIFICATE REQUEST": - public, kt = parseCSR(data) - ft = "certificate request" - default: - die.With("unknown PEM type %s", p.Type) - } - - return public, kt, ft -} - -func parseKey(data []byte) ([]byte, string) { - priv, err := certlib.ParsePrivateKeyDER(data) - if err != nil { - die.If(err) - } - - var kt string - switch priv.Public().(type) { - case *rsa.PublicKey: - kt = keyTypeRSA - case *ecdsa.PublicKey: - kt = keyTypeECDSA - default: - die.With("unknown private key type %T", priv) - } - - public, err := x509.MarshalPKIXPublicKey(priv.Public()) - die.If(err) - - return public, kt -} - -func parseCertificate(data []byte) ([]byte, string) { - cert, err := x509.ParseCertificate(data) - die.If(err) - - pub := cert.PublicKey - var kt string - switch pub.(type) { - case *rsa.PublicKey: - kt = keyTypeRSA - case *ecdsa.PublicKey: - kt = keyTypeECDSA - default: - die.With("unknown public key type %T", pub) - } - - public, err := x509.MarshalPKIXPublicKey(pub) - die.If(err) - return public, kt -} - -func parseCSR(data []byte) ([]byte, string) { - // Use certlib to support both PEM and DER and to centralize validation. - csr, _, err := certlib.ParseCSR(data) - die.If(err) - - pub := csr.PublicKey - var kt string - switch pub.(type) { - case *rsa.PublicKey: - kt = keyTypeRSA - case *ecdsa.PublicKey: - kt = keyTypeECDSA - default: - die.With("unknown public key type %T", pub) - } - - public, err := x509.MarshalPKIXPublicKey(pub) - die.If(err) - return public, kt -} - -func dumpHex(in []byte, mode lib.HexEncodeMode) string { - return lib.HexEncode(in, mode) -} - -type subjectPublicKeyInfo struct { - Algorithm pkix.AlgorithmIdentifier - SubjectPublicKey asn1.BitString -} - func main() { var help, shouldMatch bool var displayModeString string @@ -168,27 +47,22 @@ func main() { os.Exit(0) } - var ski string + var matchSKI string for _, path := range flag.Args() { - public, kt, ft := parse(path) + keyInfo, err := ski.ParsePEM(path) + die.If(err) - var subPKI subjectPublicKeyInfo - _, err := asn1.Unmarshal(public, &subPKI) - if err != nil { - _, _ = lib.Warn(err, "failed to get subject PKI") - continue + keySKI, err := keyInfo.SKI(displayMode) + die.If(err) + + if matchSKI == "" { + matchSKI = keySKI } - pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) // #nosec G401 this is the standard - pubHashString := dumpHex(pubHash[:], displayMode) - if ski == "" { - ski = pubHashString - } - - if shouldMatch && ski != pubHashString { + if shouldMatch && matchSKI != keySKI { _, _ = lib.Warnx("%s: SKI mismatch (%s != %s)", - path, ski, pubHashString) + path, matchSKI, keySKI) } - fmt.Printf("%s %s (%s %s)\n", path, pubHashString, kt, ft) + fmt.Printf("%s %s (%s %s)\n", path, keySKI, keyInfo.KeyType, keyInfo.FileType) } }