Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4851af42f | |||
| bf29d214c5 | |||
| ff34eb4eff | |||
| 7f3f513bdd | |||
| 786f116f54 | |||
| 89aaa969b8 | |||
| f5917ac6fc | |||
| 3e80e46c17 | |||
| 3c1d92db6b | |||
| 25a562865c |
28
CHANGELOG
28
CHANGELOG
@@ -1,5 +1,33 @@
|
||||
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:
|
||||
- certlib/ski: add support for return certificate SKI.
|
||||
- certlib/verify: add support for verifying certificates.
|
||||
|
||||
Changed:
|
||||
- certlib/dump: moved more functions into the dump package.
|
||||
- cmd: many certificate-related commands had their functionality moved into
|
||||
certlib.
|
||||
|
||||
v1.14.3 - 2025-11-18
|
||||
|
||||
Added:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
|
||||
135
certlib/keymatch.go
Normal file
135
certlib/keymatch.go
Normal 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())
|
||||
}
|
||||
}
|
||||
157
certlib/ski/ski.go
Normal file
157
certlib/ski/ski.go
Normal file
@@ -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
|
||||
}
|
||||
49
certlib/verify/check.go
Normal file
49
certlib/verify/check.go
Normal file
@@ -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())
|
||||
}
|
||||
143
certlib/verify/verify.go
Normal file
143
certlib/verify/verify.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Config.RootCAs == nil {
|
||||
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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
goutilslib "git.wntrmute.dev/kyle/goutils/lib"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
goutilslib "git.wntrmute.dev/kyle/goutils/lib"
|
||||
)
|
||||
|
||||
const gzipExt = ".gz"
|
||||
@@ -59,16 +61,61 @@ func buildKGExtra(uid, gid, mode uint32, ctimeS int64, ctimeNs int32) []byte {
|
||||
}
|
||||
|
||||
// Wrap in gzip subfield: [ID1 ID2 LEN(lo) LEN(hi) PAYLOAD]
|
||||
// Guard against payload length overflow to uint16 for the extra subfield length.
|
||||
if len(payload) > int(math.MaxUint16) {
|
||||
return nil
|
||||
}
|
||||
extra := make([]byte, 4+len(payload))
|
||||
extra[0] = kgzExtraID[0]
|
||||
extra[1] = kgzExtraID[1]
|
||||
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
|
||||
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)&0xFFFF)) //#nosec G115 - masked
|
||||
copy(extra[4:], payload)
|
||||
return extra
|
||||
}
|
||||
|
||||
// clampToInt32 clamps an int value into the int32 range using a switch to
|
||||
// satisfy linters that prefer switch over if-else chains for ordered checks.
|
||||
func clampToInt32(v int) int32 {
|
||||
switch {
|
||||
case v > int(math.MaxInt32):
|
||||
return math.MaxInt32
|
||||
case v < int(math.MinInt32):
|
||||
return math.MinInt32
|
||||
default:
|
||||
return int32(v)
|
||||
}
|
||||
}
|
||||
|
||||
// buildExtraForPath prepares the gzip Extra field for kgz by collecting
|
||||
// uid/gid/mode and ctime information, applying any overrides, and encoding it.
|
||||
func buildExtraForPath(st unix.Stat_t, path string, setUID, setGID int) []byte {
|
||||
uid := st.Uid
|
||||
gid := st.Gid
|
||||
if setUID >= 0 {
|
||||
if uint64(setUID) <= math.MaxUint32 {
|
||||
uid = uint32(setUID & 0xFFFFFFFF) //#nosec G115 - masked
|
||||
}
|
||||
}
|
||||
if setGID >= 0 {
|
||||
if uint64(setGID) <= math.MaxUint32 {
|
||||
gid = uint32(setGID & 0xFFFFFFFF) //#nosec G115 - masked
|
||||
}
|
||||
}
|
||||
mode := uint32(st.Mode & 0o7777)
|
||||
|
||||
// Use portable helper to gather ctime
|
||||
var cts int64
|
||||
var ctns int32
|
||||
if ft, err := goutilslib.LoadFileTime(path); err == nil {
|
||||
cts = ft.Changed.Unix()
|
||||
ctns = clampToInt32(ft.Changed.Nanosecond())
|
||||
}
|
||||
|
||||
return buildKGExtra(uid, gid, mode, cts, ctns)
|
||||
}
|
||||
|
||||
// parseKGExtra scans a gzip Extra blob and returns kgz metadata if present.
|
||||
func parseKGExtra(extra []byte) (uid, gid, mode uint32, ctimeS int64, ctimeNs int32, ok bool) {
|
||||
func parseKGExtra(extra []byte) (uint32, uint32, uint32, int64, int32, bool) {
|
||||
i := 0
|
||||
for i+4 <= len(extra) {
|
||||
id1 := extra[i]
|
||||
@@ -95,7 +142,16 @@ func parseKGExtra(extra []byte) (uid, gid, mode uint32, ctimeS int64, ctimeNs in
|
||||
if s.Version != 1 {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
return uint32(s.UID), uint32(s.GID), uint32(s.Mode), s.CTimeSec, s.CTimeNSec, true
|
||||
// Validate ranges before converting from int -> uint32 to avoid overflow.
|
||||
if s.UID < 0 || s.GID < 0 || s.Mode < 0 {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
if uint64(s.UID) > math.MaxUint32 || uint64(s.GID) > math.MaxUint32 || uint64(s.Mode) > math.MaxUint32 {
|
||||
return 0, 0, 0, 0, 0, false
|
||||
}
|
||||
|
||||
return uint32(s.UID & 0xFFFFFFFF), uint32(s.GID & 0xFFFFFFFF),
|
||||
uint32(s.Mode & 0xFFFFFFFF), s.CTimeSec, s.CTimeNSec, true //#nosec G115 - masked
|
||||
}
|
||||
i += l
|
||||
}
|
||||
@@ -111,7 +167,7 @@ func compress(path, target string, level int, includeExtra bool, setUID, setGID
|
||||
|
||||
// Gather file metadata
|
||||
var st unix.Stat_t
|
||||
if err := unix.Stat(path, &st); err != nil {
|
||||
if err = unix.Stat(path, &st); err != nil {
|
||||
return fmt.Errorf("stat source: %w", err)
|
||||
}
|
||||
fi, err := sourceFile.Stat()
|
||||
@@ -132,23 +188,7 @@ func compress(path, target string, level int, includeExtra bool, setUID, setGID
|
||||
// Set header metadata
|
||||
gzipCompressor.ModTime = fi.ModTime()
|
||||
if includeExtra {
|
||||
uid := uint32(st.Uid)
|
||||
gid := uint32(st.Gid)
|
||||
if setUID >= 0 {
|
||||
uid = uint32(setUID)
|
||||
}
|
||||
if setGID >= 0 {
|
||||
gid = uint32(setGID)
|
||||
}
|
||||
mode := uint32(st.Mode & 0o7777)
|
||||
// Use portable helper to gather ctime
|
||||
var cts int64
|
||||
var ctns int32
|
||||
if ft, err := goutilslib.LoadFileTime(path); err == nil {
|
||||
cts = ft.Changed.Unix()
|
||||
ctns = int32(ft.Changed.Nanosecond())
|
||||
}
|
||||
gzipCompressor.Extra = buildKGExtra(uid, gid, mode, cts, ctns)
|
||||
gzipCompressor.Extra = buildExtraForPath(st, path, setUID, setGID)
|
||||
}
|
||||
defer gzipCompressor.Close()
|
||||
|
||||
|
||||
156
cmd/ski/main.go
156
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
22
lib/lib.go
22
lib/lib.go
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user