From db20c70d86cfaa80ff7efb78af84a0bb483aa510 Mon Sep 17 00:00:00 2001 From: brent saner Date: Fri, 21 Jun 2024 17:18:19 -0400 Subject: [PATCH] v1.4.0 ADDED: * cryptparse, which makes managing certs/keys MUCH much easier (and better) than the stdlib utilities. --- cryptparse/TODO | 3 + cryptparse/consts.go | 123 ++++++ cryptparse/errs.go | 12 + cryptparse/funcs.go | 751 ++++++++++++++++++++++++++++++++++++ cryptparse/funcs_tlsflat.go | 217 +++++++++++ cryptparse/funcs_tlsuri.go | 159 ++++++++ cryptparse/types.go | 30 ++ go.mod | 13 +- go.sum | 38 +- 9 files changed, 1333 insertions(+), 13 deletions(-) create mode 100644 cryptparse/TODO create mode 100644 cryptparse/consts.go create mode 100644 cryptparse/errs.go create mode 100644 cryptparse/funcs.go create mode 100644 cryptparse/funcs_tlsflat.go create mode 100644 cryptparse/funcs_tlsuri.go create mode 100644 cryptparse/types.go diff --git a/cryptparse/TODO b/cryptparse/TODO new file mode 100644 index 0000000..a02d066 --- /dev/null +++ b/cryptparse/TODO @@ -0,0 +1,3 @@ +- PKCS#12/PFX parsing/support + +- Move to struct tags and reflection, so it can not only be easier to maintain in the future but also be implemented in custom structs downstream. diff --git a/cryptparse/consts.go b/cryptparse/consts.go new file mode 100644 index 0000000..2201ad5 --- /dev/null +++ b/cryptparse/consts.go @@ -0,0 +1,123 @@ +package cryptparse + +import ( + `crypto/tls` + + `github.com/go-playground/validator/v10` +) + +var ( + tlsVerNmToUint map[string]uint16 + tlsCipherNmToUint map[string]uint16 + tlsCurveNmToCurve map[string]tls.CurveID +) + +const ( + MaxTlsCipher uint16 = tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + MaxCurveId tls.CurveID = tls.X25519 // 29 + MinTlsVer uint16 = tls.VersionSSL30 + MaxTlsVer uint16 = tls.VersionTLS13 +) + +// TlsUriParam* specifiy URL query parameters to parse a tls:// URI. +const ( + /* + TlsUriParamCa specifies a path to a CA certificate PEM-encded DER file. + + It may be specified multiple times in a TLS URI. + */ + TlsUriParamCa string = "pki_ca" + /* + TlsUriParamCert specifies a path to a client certificate PEM-encded DER file. + + It may be specified multiple times in a TLS URI. + */ + TlsUriParamCert string = "pki_cert" + /* + TlsUriParamKey specifies a path to a private key as a PEM-encded file. + + It may be PKCS#1, PKCS#8, or PEM-encoded ASN.1 DER EC key. + + Supported private key types are RSA, ED25519, ECDSA, and ECDH. + + It may be specified multiple times in a TLS URI. + */ + TlsUriParamKey string = "pki_key" + /* + TlsUriParamNoVerify, if `1`, `yes`, `y`, or `true` indicate + that the TLS connection should not require verification of + the remote end (e.g. hostname matches, trusted chain, etc.). + + Any other value for this parameter will be parsed as "False" + (meaning the remote end's certificate SHOULD be verified). + + Only the first defined instance is parsed. + */ + TlsUriParamNoVerify string = "no_verify" + /* + TlsUriParamSni indicates that the TLS connection should expect this hostname + instead of the hostname specified in the URI itself. + + Only the first defined instance is parsed. + */ + TlsUriParamSni string = "sni" + /* + TlsUriParamCipher specifies one (or more) cipher(s) + to specify for the TLS connection cipher negotiation. + Note that TLS 1.3 has a fixed set of ciphers, and + this list may not be respected by the remote end. + + The string may either be the name (as per + https://www.iana.org/assignments/tls-parameters/tls-parameters.xml) + or an int (normal, hex, etc. string representation). + + It may be specified multiple times in a TLS URI. + */ + TlsUriParamCipher string = "cipher" + /* + TlsUriParamCurve specifies one (or more) curve(s) + to specify for the TLS connection cipher negotiation. + + It may be specified multiple times in a TLS URI. + */ + TlsUriParamCurve string = "curve" + /* + TlsUriParamMinTls defines the minimum version of the + TLS protocol to use. + It is recommended to use "TLS_1.3". + + Supported syntax formats include: + + * TLS_1.3 + * 1.3 + * v1.3 + * TLSv1.3 + * 0x0304 (legacy_version, see RFC8446 ยง 4.1.2) + * 774 (0x0304 in int form) + * 0o1404 (0x0304 in octal form) + + All evaluate to TLS 1.3 in this example. + + Only the first defined instance is parsed. + */ + TlsUriParamMinTls string = "min_tls" + /* + TlsUriParamMaxTls defines the minimum version of the + TLS protocol to use. + + See TlsUriParamMinTls for syntax of the value. + + Only the first defined instance is parsed. + */ + TlsUriParamMaxTls string = "max_tls" +) + +var ( + paramBoolValsTrue []string = []string{ + "1", "yes", "y", "true", + } + paramBoolValsFalse []string = []string{ + "0", "no", "n", "false", + } + validate *validator.Validate = validator.New(validator.WithRequiredStructEnabled()) +) diff --git a/cryptparse/errs.go b/cryptparse/errs.go new file mode 100644 index 0000000..4debd81 --- /dev/null +++ b/cryptparse/errs.go @@ -0,0 +1,12 @@ +package cryptparse + +import ( + `errors` +) + +var ( + ErrBadTlsCipher error = errors.New("invalid TLS cipher suite") + ErrBadTlsCurve error = errors.New("invalid TLS curve") + ErrBadTlsVer error = errors.New("invalid TLS version") + ErrUnknownKey error = errors.New("unknown key type") +) diff --git a/cryptparse/funcs.go b/cryptparse/funcs.go new file mode 100644 index 0000000..e9aeec2 --- /dev/null +++ b/cryptparse/funcs.go @@ -0,0 +1,751 @@ +package cryptparse + +import ( + `bytes` + `crypto` + `crypto/ecdh` + `crypto/ecdsa` + `crypto/ed25519` + `crypto/rsa` + `crypto/tls` + `crypto/x509` + `encoding/pem` + `errors` + `net/url` + `os` + `strconv` + `strings` + + `r00t2.io/sysutils/paths` +) + +// FromURL returns a *TlsUri from a *url.URL. +func FromURL(u *url.URL) (t *TlsUri) { + + var newU *url.URL + + if u == nil { + return + } + + newU = new(url.URL) + *newU = *u + if u.User != nil { + newU.User = new(url.Userinfo) + *newU.User = *u.User + } + + newU.Scheme = "tls" + + t = &TlsUri{ + URL: newU, + } + + return +} + +// IsMatchedPair returns true if the privateKey is paired with the cert. +func IsMatchedPair(privKey crypto.PrivateKey, cert *x509.Certificate) (isMatched bool, err error) { + + var pubkey crypto.PublicKey + + if cert == nil || privKey == nil { + return + } + + pubkey = cert.PublicKey + + switch k := privKey.(type) { + case *rsa.PrivateKey: + if p, ok := pubkey.(*rsa.PublicKey); ok { + isMatched = k.PublicKey.Equal(p) + return + } + case ed25519.PrivateKey: + if p, ok := pubkey.(ed25519.PublicKey); ok { + // Order is flipped here because unlike the other key types, an ed25519.PrivateKey is just a []byte. + isMatched = p.Equal(k.Public()) + return + } + case *ecdh.PrivateKey: + if p, ok := pubkey.(*ecdh.PublicKey); ok { + isMatched = k.PublicKey().Equal(p) + return + } + case *ecdsa.PrivateKey: + if p, ok := pubkey.(*ecdsa.PublicKey); ok { + isMatched = k.PublicKey.Equal(p) + return + } + } + + // If we got here, we can't determine either the private key type or the cert's public key type. + err = ErrUnknownKey + + return +} + +/* + ParseTlsCipher parses string s and attempts to derive a TLS cipher suite (as a uint16) from it. + Use ParseTlsCipherSuite if you wish for a tls.CipherSuite instead. + + The string may either be the name (as per https://www.iana.org/assignments/tls-parameters/tls-parameters.xml) + or an int (normal, hex, etc. string representation). + + If none is found, the default is MaxTlsCipher. +*/ +func ParseTlsCipher(s string) (cipherSuite uint16, err error) { + + var nm string + var n uint64 + var i uint16 + var ok bool + + if n, err = strconv.ParseUint(s, 10, 16); err != nil { + if errors.Is(err, strconv.ErrSyntax) { + // It's a name; parse below. + err = nil + } else { + return + } + } else { + // It's a number. + if nm = tls.CipherSuiteName(uint16(n)); strings.HasPrefix(nm, "0x") { + // ...but invalid. + err = ErrBadTlsCipher + return + } else { + // Valid (as number). Return it. + cipherSuite = uint16(n) + return + } + } + + s = strings.ToUpper(s) + s = strings.ReplaceAll(s, " ", "_") + + // We build a dynamic map of cipher suite names to uint16s (if not already created). + if tlsCipherNmToUint == nil { + tlsCipherNmToUint = make(map[string]uint16) + for i = 0; i <= MaxTlsCipher; i++ { + if nm = tls.VersionName(i); !strings.HasPrefix(nm, "0x") { + tlsCipherNmToUint[nm] = i + } + } + } + + cipherSuite = MaxTlsCipher + if i, ok = tlsCipherNmToUint[s]; ok { + cipherSuite = i + } + + return +} + +/* + ParseTlsCiphers parses s as a comma-separated list of cipher suite names/integers and returns a slice of suites. + + See ParseTlsCipher for details, as this is mostly just a wrapper around it. + + If no cipher suites are found, cipherSuites will only contain MaxTlsCipher. +*/ +func ParseTlsCiphers(s string) (cipherSuites []uint16) { + + var suiteNms []string + var cipher uint16 + var err error + + suiteNms = strings.Split(s, ",") + cipherSuites = make([]uint16, 0, len(suiteNms)) + + for _, nm := range suiteNms { + if cipher, err = ParseTlsCipher(nm); err != nil { + err = nil + continue + } + cipherSuites = append(cipherSuites, cipher) + } + + if len(cipherSuites) == 0 { + cipherSuites = []uint16{tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256} + } + + return +} + +// ParseTlsCipherSuite is like ParseTlsCipher but returns a *tls.CipherSuite instead of a uint16 TLS cipher identifier. +func ParseTlsCipherSuite(s string) (cipherSuite *tls.CipherSuite, err error) { + + var cipherId uint16 + + if cipherId, err = ParseTlsCipher(s); err != nil { + return + } + + for _, v := range tls.CipherSuites() { + if v.ID == cipherId { + cipherSuite = v + return + } + } + for _, v := range tls.InsecureCipherSuites() { + if v.ID == cipherId { + cipherSuite = v + return + } + } + + return +} + +// ParseTlsCipherSuites is like ParseTlsCiphers but returns a []*tls.CipherSuite instead of a []uint16 of TLS cipher identifiers. +func ParseTlsCipherSuites(s string) (cipherSuites []*tls.CipherSuite, err error) { + + var found bool + var cipherIds []uint16 + + cipherIds = ParseTlsCiphers(s) + + for _, cipherId := range cipherIds { + found = false + for _, v := range tls.CipherSuites() { + if v.ID == cipherId { + cipherSuites = append(cipherSuites, v) + found = true + break + } + } + if !found { + for _, v := range tls.InsecureCipherSuites() { + if v.ID == cipherId { + cipherSuites = append(cipherSuites, v) + break + } + } + } + } + + return +} + +/* + ParseTlsCurve parses string s and attempts to derive a tls.CurveID from it. + + The string may either be the name (as per // https://www.iana.org/assignments/tls-parameters/tls-parameters.xml#tls-parameters-8) + or an int (normal, hex, etc. string representation). +*/ +func ParseTlsCurve(s string) (curve tls.CurveID, err error) { + + var i tls.CurveID + var n uint64 + var ok bool + + if n, err = strconv.ParseUint(s, 10, 16); err != nil { + if errors.Is(err, strconv.ErrSyntax) { + // It's a name; parse below. + err = nil + } else { + return + } + } else { + // It's a number. + if strings.HasPrefix(tls.CurveID(uint16(n)).String(), "CurveID(") { + // ...but invalid. + err = ErrBadTlsCurve + return + } else { + // Valid (as number). Return it. + curve = tls.CurveID(uint16(n)) + return + } + } + + // It seems to be a name. Normalize... + s = strings.ToUpper(s) + + // Unfortunately there's no "tls.CurveIDName()" function. + // They do have a .String() method though. + if tlsCurveNmToCurve == nil { + tlsCurveNmToCurve = make(map[string]tls.CurveID) + for i = 0; i <= MaxCurveId; i++ { + if strings.HasPrefix(i.String(), "CurveID(") { + continue + } + tlsCurveNmToCurve[i.String()] = i + // It's normally mixed-case; we want to be able to look it up in a normalized all-caps as well. + tlsCurveNmToCurve[strings.ToUpper(i.String())] = i + // The normal name, except for X25519, has "Curve" in the front. We add it without that prefix as well. + tlsCurveNmToCurve[strings.TrimPrefix(i.String(), "Curve")] = i + } + } + + curve = MaxCurveId + if _, ok = tlsCurveNmToCurve[s]; ok { + curve = tlsCurveNmToCurve[s] + } + + return +} + +/* + ParseTlsCurves parses s as a comma-separated list of tls.CurveID names/integers and returns a slice of tls.CurveID. + + See ParseTlsCurve for details, as this is mostly just a wrapper around it. + + If no curves are found, curves will only contain MaxCurveId. +*/ +func ParseTlsCurves(s string) (curves []tls.CurveID) { + + var curveNms []string + var curve tls.CurveID + var err error + + curveNms = strings.Split(s, ",") + curves = make([]tls.CurveID, 0, len(curveNms)) + + for _, nm := range curveNms { + if curve, err = ParseTlsCurve(nm); err != nil { + err = nil + continue + } + curves = append(curves, curve) + } + + if len(curves) == 0 { + curves = []tls.CurveID{MaxCurveId} + } + + return +} + +/* + ParseTlsUri parses a "TLS URI"'s query parameters. All certs and keys must be in PEM format. + + You probably don't need this and should instead be using TlsUri.ToTlsConfig. + It just wraps this, but is probably more convenient. +*/ +func ParseTlsUri(tlsUri *url.URL) (tlsConf *tls.Config, err error) { + + var b []byte + var rootCAs *x509.CertPool + var intermediateCAs []*x509.Certificate + var privKeys []crypto.PrivateKey + var tlsCerts []tls.Certificate + var allowInvalid bool + var ciphers []uint16 + var curves []tls.CurveID + var params map[string][]string + var ok bool + var val string + var minVer uint16 + var maxVer uint16 + var buf *bytes.Buffer = new(bytes.Buffer) + var srvNm string = tlsUri.Hostname() + + params = tlsUri.Query() + + if params == nil { + tlsConf = &tls.Config{ + ServerName: srvNm, + } + return + } + + // These are all filepath(s). + for _, k := range []string{ + TlsUriParamCa, + TlsUriParamCert, + TlsUriParamKey, + } { + if _, ok = params[k]; ok { + for idx, _ := range params[k] { + if err = paths.RealPath(¶ms[k][idx]); err != nil { + return + } + } + } + } + + // CA cert(s). + buf.Reset() + if _, ok = params[TlsUriParamCa]; ok { + rootCAs = x509.NewCertPool() + for _, c := range params[TlsUriParamCa] { + if b, err = os.ReadFile(c); err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + continue + } + } + buf.Write(b) + } + if rootCAs, _, intermediateCAs, err = ParseCA(buf.Bytes()); err != nil { + return + } + } else { + if rootCAs, err = x509.SystemCertPool(); err != nil { + return + } + } + + // Keys. These are done first so we can match to a client certificate. + buf.Reset() + if _, ok = params[TlsUriParamKey]; ok { + for _, k := range params[TlsUriParamKey] { + if b, err = os.ReadFile(k); err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + continue + } else { + return + } + } + buf.Write(b) + } + if privKeys, err = ParsePrivateKey(buf.Bytes()); err != nil { + return + } + } + + // (Client) Certificate(s). + buf.Reset() + if _, ok = params[TlsUriParamCert]; ok { + for _, c := range params[TlsUriParamCert] { + if b, err = os.ReadFile(c); err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + continue + } else { + return + } + } + buf.Write(b) + } + if tlsCerts, err = ParseLeafCert(buf.Bytes(), privKeys, intermediateCAs...); err != nil { + return + } + } + + // Hostname (Override). + if _, ok = params[TlsUriParamSni]; ok { + srvNm = params[TlsUriParamSni][0] + } + + // Disable Verification. + if _, ok = params[TlsUriParamNoVerify]; ok { + val = strings.ToLower(params[TlsUriParamNoVerify][0]) + for _, i := range paramBoolValsTrue { + if i == val { + allowInvalid = true + break + } + } + } + + // Ciphers. + if _, ok = params[TlsUriParamCipher]; ok { + ciphers = ParseTlsCiphers(strings.Join(params[TlsUriParamCipher], ",")) + } + + // Minimum TLS Protocol Version. + if _, ok = params[TlsUriParamMinTls]; ok { + if minVer, err = ParseTlsVersion(params[TlsUriParamMinTls][0]); err != nil { + return + } + } + + // Maximum TLS Protocol Version. + if _, ok = params[TlsUriParamMaxTls]; ok { + if maxVer, err = ParseTlsVersion(params[TlsUriParamMaxTls][0]); err != nil { + return + } + } + + // Curves. + if _, ok = params[TlsUriParamCurve]; ok { + curves = ParseTlsCurves(strings.Join(params[TlsUriParamCurve], ",")) + } + + tlsConf = &tls.Config{ + Certificates: tlsCerts, + RootCAs: rootCAs, + ServerName: srvNm, + InsecureSkipVerify: allowInvalid, + CipherSuites: ciphers, + MinVersion: minVer, + MaxVersion: maxVer, + CurvePreferences: curves, + } + + return +} + +// ParseTlsVersion parses string s and attempts to derive a TLS version from it. If none is found, tlsVer will be 0. +func ParseTlsVersion(s string) (tlsVer uint16, err error) { + + var nm string + var n uint64 + var i uint16 + var ok bool + + if n, err = strconv.ParseUint(s, 10, 16); err != nil { + if errors.Is(err, strconv.ErrSyntax) { + // It's a name; parse below. + err = nil + } else { + return + } + } else { + // It's a number. + if nm = tls.VersionName(uint16(n)); strings.HasPrefix(nm, "0x") { + // ...but invalid. + err = ErrBadTlsVer + return + } else { + // Valid (as number). Return it. + tlsVer = uint16(n) + return + } + } + + // If we get here, it should be parsed as a version string. + s = strings.ToUpper(s) + s = strings.ReplaceAll(s, "_", " ") + s = strings.ReplaceAll(s, "V", " ") + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "SSL") && !strings.HasPrefix(s, "TLS ") { + s = "TLS " + s + } + + // We build a dynamic map of version names to uint16s (if not already created). + if tlsVerNmToUint == nil { + tlsVerNmToUint = make(map[string]uint16) + for i = MinTlsVer; i <= MaxTlsVer; i++ { + if nm = tls.VersionName(i); !strings.HasPrefix(nm, "0x") { + tlsVerNmToUint[nm] = i + } + } + } + + if i, ok = tlsVerNmToUint[s]; ok { + tlsVer = i + } + + return +} + +/* + ParseCA parses PEM bytes and returns an *x509.CertPool in caCerts. + + Concatenated PEM files are supported. + + Any keys found will be filtered out, as will any leaf certificates. + + Any *intermediate* CAs (the certificate is a CA but it is not self-signed) will be returned separate from + certPool. + + Ordering from the file is preserved in the returned slices. +*/ +func ParseCA(certRaw []byte) (certPool *x509.CertPool, rootCerts []*x509.Certificate, intermediateCerts []*x509.Certificate, err error) { + + var pemBlocks []*pem.Block + var cert *x509.Certificate + var certs []*x509.Certificate + + if pemBlocks, err = SplitPem(certRaw); err != nil { + return + } + + // Filter out keys etc. and non-CA certs. + for _, b := range pemBlocks { + if b.Type != "CERTIFICATE" { + continue + } + if cert, err = x509.ParseCertificate(b.Bytes); err != nil { + return + } + if !cert.IsCA { + continue + } + certs = append(certs, cert) + } + + for _, cert = range certs { + if bytes.Equal(cert.RawIssuer, cert.RawSubject) { + // It's a root/self-signed. + rootCerts = append(rootCerts, cert) + } else { + // It's an intermediate. + intermediateCerts = append(intermediateCerts, cert) + } + } + + if rootCerts != nil { + certPool = x509.NewCertPool() + for _, cert = range rootCerts { + certPool.AddCert(cert) + } + } + + return +} + +/* + ParseLeafCert parses PEM bytes from a (client) certificate file, iterates over a slice of + crypto.PrivateKey (finding one that matches), and returns one (or more) tls.Certificate. + + The key may also be combined with the certificate in the same file. + + If no private key matches or no client cert is found in the file, tlsCerts will be nil/missing + that certificate but no error will be returned. + This behavior can be avoided by passing a nil slice to keys. + + Any leaf certificates ("server" certificate, as opposed to a signer/issuer) found in the file + will be assumed to be the desired one(s). + + Any additional/supplementary intermediates may be provided. Any present in the PEM bytes (certRaw) will be included. + + Any *root* CAs found will be discarded. They should/can be extracted seperately via ParseCA. + + The parsed and paired certificates and keys can be found in each respective tls.Certificate.Leaf and tls.Certificate.PrivateKey. + Any certs without a corresponding key will be discarded. +*/ +func ParseLeafCert(certRaw []byte, keys []crypto.PrivateKey, intermediates ...*x509.Certificate) (tlsCerts []tls.Certificate, err error) { + + var pemBlocks []*pem.Block + var cert *x509.Certificate + var certs []*x509.Certificate + var caCerts []*x509.Certificate + var parsedKeys []crypto.PrivateKey + var isMatched bool + var foundKey crypto.PrivateKey + var interBytes [][]byte + var skipKeyPair bool = keys == nil + var parsedKeysBuf *bytes.Buffer = new(bytes.Buffer) + + if pemBlocks, err = SplitPem(certRaw); err != nil { + return + } + + for _, b := range pemBlocks { + if strings.Contains(b.Type, "PRIVATE KEY") { + parsedKeysBuf.Write(pem.EncodeToMemory(b)) + continue + } + if b.Type != "CERTIFICATE" { + continue + } + if cert, err = x509.ParseCertificate(b.Bytes); err != nil { + return + } + if cert.IsCA { + if bytes.Equal(cert.RawIssuer, cert.RawSubject) { + caCerts = append(caCerts, cert) + } else { + intermediates = append(intermediates, cert) + } + } + certs = append(certs, cert) + } + + if intermediates != nil && len(intermediates) != 0 { + interBytes = make([][]byte, len(intermediates)) + for _, i := range intermediates { + interBytes = append(interBytes, i.Raw) + } + } + + if parsedKeysBuf.Len() != 0 { + if parsedKeys, err = ParsePrivateKey(parsedKeysBuf.Bytes()); err != nil { + return + } + keys = append(keys, parsedKeys...) + } + + // Now pair the certs and keys, and combine as a tls.Certificate. + for _, cert = range certs { + foundKey = nil + for _, k := range keys { + if isMatched, err = IsMatchedPair(k, cert); err != nil { + return + } + if isMatched { + foundKey = k + break + } + } + if foundKey == nil && !skipKeyPair { + continue + } + tlsCerts = append( + tlsCerts, + tls.Certificate{ + Certificate: append([][]byte{cert.Raw}, interBytes...), + PrivateKey: foundKey, + Leaf: cert, + }, + ) + } + + _ = caCerts + + return +} + +/* + ParsePrivateKey parses PEM bytes to a private key. Multiple keys may be concatenated in the same file. + + Any public keys, certificates, etc. found will be discarded. +*/ +func ParsePrivateKey(keyRaw []byte) (keys []crypto.PrivateKey, err error) { + + var privKey crypto.PrivateKey + var pemBlocks []*pem.Block + + if pemBlocks, err = SplitPem(keyRaw); err != nil { + return + } + + for _, b := range pemBlocks { + if !strings.Contains(b.Type, "PRIVATE KEY") { + continue + } + switch b.Type { + case "RSA PRIVATE KEY": // PKCS#1 + if privKey, err = x509.ParsePKCS1PrivateKey(b.Bytes); err != nil { + return + } + keys = append(keys, privKey) + case "EC PRIVATE KEY": // SEC 1, ASN.1 DER + if privKey, err = x509.ParseECPrivateKey(b.Bytes); err != nil { + return + } + keys = append(keys, privKey) + case "PRIVATE KEY": // PKCS#8 + if privKey, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil { + return + } + keys = append(keys, privKey) + default: + err = ErrUnknownKey + return + } + } + + // TODO + + return +} + +// SplitPem splits a single block of bytes into one (or more) (encoding/)pem.Blocks. +func SplitPem(pemRaw []byte) (blocks []*pem.Block, err error) { + + var block *pem.Block + var rest []byte + + for block, rest = pem.Decode(pemRaw); block != nil; block, rest = pem.Decode(rest) { + blocks = append(blocks, block) + } + + return +} diff --git a/cryptparse/funcs_tlsflat.go b/cryptparse/funcs_tlsflat.go new file mode 100644 index 0000000..b205e75 --- /dev/null +++ b/cryptparse/funcs_tlsflat.go @@ -0,0 +1,217 @@ +package cryptparse + +import ( + `bytes` + `crypto` + `crypto/tls` + `crypto/x509` + `errors` + `fmt` + `net/url` + `os` + `strings` + + `r00t2.io/sysutils/paths` +) + +// Normalize ensures that all specified filepaths are absolute, etc. +func (t *TlsFlat) Normalize() (err error) { + + if t.Certs != nil { + for _, c := range t.Certs { + if err = paths.RealPath(&c.CertFile); err != nil { + return + } + if c.KeyFile != nil { + if err = paths.RealPath(c.KeyFile); err != nil { + return + } + } + } + } + if t.CaFiles != nil { + for idx, _ := range t.CaFiles { + if err = paths.RealPath(&t.CaFiles[idx]); err != nil { + return + } + } + } + + return +} + +/* + ToTlsConfig returns a tls.Config from a TlsFlat. Note that it will have Normalize called on it. + + Unfortunately it's not possible for this library to do the reverse, as CA certificates are not able to be extracted from an x509.CertPool. +*/ +func (t *TlsFlat) ToTlsConfig() (tlsConf *tls.Config, err error) { + + var b []byte + var rootCAs *x509.CertPool + var intermediateCAs []*x509.Certificate + var privKeys []crypto.PrivateKey + var tlsCerts []tls.Certificate + var parsedTlsCerts []tls.Certificate + var ciphers []uint16 + var curves []tls.CurveID + var minVer uint16 + var maxVer uint16 + var buf *bytes.Buffer = new(bytes.Buffer) + var srvNm string = t.SniName + + // Normalize any filepaths before validation. + if err = t.Normalize(); err != nil { + return + } + + // And validate. + if err = validate.Struct(t); err != nil { + return + } + + // CA cert(s). + buf.Reset() + if t.CaFiles != nil { + rootCAs = x509.NewCertPool() + for _, c := range t.CaFiles { + if b, err = os.ReadFile(c); err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + continue + } + } + buf.Write(b) + } + if rootCAs, _, intermediateCAs, err = ParseCA(buf.Bytes()); err != nil { + return + } + } else { + if rootCAs, err = x509.SystemCertPool(); err != nil { + return + } + } + + // Keys and Certs. They are assumed to be matched. + if t.Certs != nil { + for _, c := range t.Certs { + privKeys = nil + if c.KeyFile != nil { + if b, err = os.ReadFile(*c.KeyFile); err != nil { + return + } + if privKeys, err = ParsePrivateKey(b); err != nil { + return + } + } + if b, err = os.ReadFile(c.CertFile); err != nil { + return + } + if parsedTlsCerts, err = ParseLeafCert(b, privKeys, intermediateCAs...); err != nil { + return + } + tlsCerts = append(tlsCerts, parsedTlsCerts...) + } + } + + // Ciphers. + if t.CipherSuites != nil { + ciphers = ParseTlsCiphers(strings.Join(t.CipherSuites, ",")) + } + + // Minimum TLS Protocol Version. + if t.MinTlsProtocol != nil { + if minVer, err = ParseTlsVersion(*t.MinTlsProtocol); err != nil { + return + } + } + + // Maximum TLS Protocol Version. + if t.MaxTlsProtocol != nil { + if maxVer, err = ParseTlsVersion(*t.MaxTlsProtocol); err != nil { + return + } + } + + // Curves. + if t.Curves != nil { + curves = ParseTlsCurves(strings.Join(t.Curves, ",")) + } + + tlsConf = &tls.Config{ + Certificates: tlsCerts, + RootCAs: rootCAs, + ServerName: srvNm, + InsecureSkipVerify: t.SkipVerify, + CipherSuites: ciphers, + MinVersion: minVer, + MaxVersion: maxVer, + CurvePreferences: curves, + } + return +} + +// ToTlsUri returns a TlsUri from a TlsFlat. +func (t *TlsFlat) ToTlsUri() (tlsUri *TlsUri, err error) { + + var u *url.URL + + if u, err = url.Parse(fmt.Sprintf("tls://%v/", t.SniName)); err != nil { + return + } + + // CA cert(s). + if t.CaFiles != nil { + for _, c := range t.CaFiles { + u.Query().Add(TlsUriParamCa, c) + } + } + + // Keys and Certs. + if t.Certs != nil { + for _, c := range t.Certs { + u.Query().Add(TlsUriParamCert, c.CertFile) + if c.KeyFile != nil { + u.Query().Add(TlsUriParamKey, *c.KeyFile) + } + } + } + + // Enforce the SNI hostname. + u.Query().Add(TlsUriParamSni, t.SniName) + + // Disable Verification. + if t.SkipVerify { + u.Query().Add(TlsUriParamNoVerify, "1") + } + + // Ciphers. + if t.CipherSuites != nil { + for _, c := range t.CipherSuites { + u.Query().Add(TlsUriParamCipher, c) + } + } + + // Minimum TLS Protocol Version. + if t.MinTlsProtocol != nil { + u.Query().Add(TlsUriParamMinTls, *t.MinTlsProtocol) + } + + // Maximum TLS Protocol Version. + if t.MaxTlsProtocol != nil { + u.Query().Add(TlsUriParamMaxTls, *t.MaxTlsProtocol) + } + + // Curves. + if t.Curves != nil { + for _, c := range t.Curves { + u.Query().Add(TlsUriParamCurve, c) + } + } + + tlsUri = &TlsUri{ + URL: u, + } + + return +} diff --git a/cryptparse/funcs_tlsuri.go b/cryptparse/funcs_tlsuri.go new file mode 100644 index 0000000..482c264 --- /dev/null +++ b/cryptparse/funcs_tlsuri.go @@ -0,0 +1,159 @@ +package cryptparse + +import ( + `crypto` + `crypto/tls` + `net/url` + `os` + `strings` +) + +/* + ToTlsConfig returns a *tls.Config from a TlsUri. + + Unfortunately it's not possible for this library to do the reverse, as CA certificates are not able to be extracted from an x509.CertPool. +*/ +func (t *TlsUri) ToTlsConfig() (cfg *tls.Config, err error) { + + if cfg, err = ParseTlsUri(t.URL); err != nil { + return + } + + return +} + +// ToTlsFlat returns a *TlsFlat from a TlsUri. +func (t *TlsUri) ToTlsFlat() (tlsFlat *TlsFlat, err error) { + + var b []byte + var params url.Values + var paramMap map[string][]string + // These also have maps so they can backmap filenames. + var privKeys []crypto.PrivateKey + var privKeyMap map[string][]crypto.PrivateKey + var tlsCerts []tls.Certificate + var tlsCertMap map[string][]tls.Certificate + var isMatch bool + var fCert *TlsFlatCert + var val string + var f TlsFlat = TlsFlat{ + SniName: t.Hostname(), + SkipVerify: false, + Certs: nil, + CaFiles: nil, + CipherSuites: nil, + MinTlsProtocol: nil, + MaxTlsProtocol: nil, + Curves: nil, + } + + params = t.Query() + paramMap = params + + if params == nil { + tlsFlat = &f + return + } + + // CA cert(s). + if t.Query().Has(TlsUriParamCa) { + f.CaFiles = append(f.CaFiles, paramMap[TlsUriParamCa]...) + } + + // Keys and Certs. These are done first so we can match to a client certificate. + if t.Query().Has(TlsUriParamKey) { + privKeyMap = make(map[string][]crypto.PrivateKey) + for _, kFile := range paramMap[TlsUriParamKey] { + if b, err = os.ReadFile(kFile); err != nil { + return + } + if privKeyMap[kFile], err = ParsePrivateKey(b); err != nil { + return + } + privKeys = append(privKeys, privKeyMap[kFile]...) + } + } + if t.Query().Has(TlsUriParamCert) { + tlsCertMap = make(map[string][]tls.Certificate) + for _, cFile := range paramMap[TlsUriParamCert] { + if b, err = os.ReadFile(cFile); err != nil { + return + } + if tlsCertMap[cFile], err = ParseLeafCert(b, privKeys); err != nil { + return + } + tlsCerts = append(tlsCerts, tlsCertMap[cFile]...) + } + } + // We then correlate. Whew, lads. + for cFile, c := range tlsCertMap { + for _, cert := range c { + for kFile, k := range privKeyMap { + if isMatch, err = IsMatchedPair(k, cert.Leaf); err != nil { + return + } else if isMatch { + fCert = &TlsFlatCert{ + CertFile: cFile, + KeyFile: new(string), + } + *fCert.KeyFile = kFile + f.Certs = append(f.Certs, fCert) + } + } + } + } + + // Hostname. + if t.Query().Has(TlsUriParamSni) { + f.SniName = t.Query().Get(TlsUriParamSni) + } + + // Disable verification. + if t.Query().Has(TlsUriParamNoVerify) { + val = strings.ToLower(t.Query().Get(TlsUriParamNoVerify)) + for _, i := range paramBoolValsTrue { + if val == i { + f.SkipVerify = true + break + } + } + } + + // Ciphers. + if t.Query().Has(TlsUriParamCipher) { + f.CipherSuites = params[TlsUriParamCipher] + } + + // Minimum TLS Protocol Version. + if t.Query().Has(TlsUriParamMinTls) { + f.MinTlsProtocol = new(string) + *f.MinTlsProtocol = t.Query().Get(TlsUriParamMinTls) + } + + // Maximum TLS Protocol Version. + if t.Query().Has(TlsUriParamMaxTls) { + f.MaxTlsProtocol = new(string) + *f.MaxTlsProtocol = t.Query().Get(TlsUriParamMaxTls) + } + + // Curves. + if t.Query().Has(TlsUriParamCurve) { + f.Curves = params[TlsUriParamCurve] + } + + tlsFlat = &f + + return +} + +// ToURL returns the *url.URL representation of a TlsUri. +func (t *TlsUri) ToURL() (u *url.URL) { + + if t == nil { + return + } + + u = t.URL + + return +} diff --git a/cryptparse/types.go b/cryptparse/types.go new file mode 100644 index 0000000..263849d --- /dev/null +++ b/cryptparse/types.go @@ -0,0 +1,30 @@ +package cryptparse + +import ( + `encoding/xml` + `net/url` +) + +// TlsFlat provides an easy structure to marshal/unmarshal a tls.Config from/to a data structure (JSON, XML, etc.). +type TlsFlat struct { + XMLName xml.Name `xml:"tlsConfig" json:"-" yaml:"-" toml:"-"` + SniName string `json:"sni_name" xml:"sniName,attr" yaml:"SniName" toml:"SniName" required:"true" validate:"required"` + SkipVerify bool `json:"skip_verify,omitempty" xml:"skipVerify,attr,omitempty" yaml:"SkipVerify,omitempty" toml:"SkipVerify,omitempty"` + Certs []*TlsFlatCert `json:"certs,omitempty" xml:"certs>cert,omitempty" yaml:"Certs,omitempty" toml:"Certs,omitempty" validate:"omitempty,dive"` + CaFiles []string `json:"ca_files,omitempty" xml:"roots>ca,omitempty" yaml:"CaFiles,omitempty" toml:"CaFiles,omitempty" validate:"omitempty,dive,filepath"` + CipherSuites []string `json:"cipher_suites,omitempty" xml:"ciphers,omitempty" yaml:"CipherSuites,omitempty" toml:"CipherSuites,omitempty"` + MinTlsProtocol *string `json:"min_tls_protocol,omitempty" xml:"minTlsProtocol,attr,omitempty" yaml:"MinTlsProtocol,omitempty" toml:"MinTlsProtocol,omitempty"` + MaxTlsProtocol *string `json:"max_tls_protocol,omitempty" xml:"maxTlsProtocol,attr,omitempty" yaml:"MaxTlsProtocol,omitempty" toml:"MaxTlsProtocol,omitempty"` + Curves []string `json:"curves,omitempty" xml:"curves>curve,omitempty" yaml:"Curves,omitempty" toml:"Curves,omitempty" validate:"omitempty,dive"` +} + +// TlsFlatCert represents a certificate (and, possibly, paired key). +type TlsFlatCert struct { + XMLName xml.Name `xml:"cert" json:"-" yaml:"-" toml:"-"` + KeyFile *string `json:"key,omitempty" xml:"key,attr,omitempty" yaml:"Key,omitempty" toml:"Key,omitempty" validate:"omitempty,filepath"` + CertFile string `json:"cert" xml:",chardata" yaml:"Certificate" toml:"Certificate" required:"true" validate:"required,filepath"` +} + +type TlsUri struct { + *url.URL +} diff --git a/go.mod b/go.mod index ed127b4..7fbbe59 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,21 @@ go 1.21 require ( github.com/davecgh/go-spew v1.1.1 github.com/g0rbe/go-chattr v1.0.1 - github.com/google/uuid v1.6.0 + github.com/go-playground/validator/v10 v10.22.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 golang.org/x/sys v0.19.0 + honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 r00t2.io/goutils v1.6.0 ) require ( - github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect - github.com/godbus/dbus v4.1.0+incompatible // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/text v0.14.0 // indirect ) // Pending https://github.com/g0rbe/go-chattr/pull/3 diff --git a/go.sum b/go.sum index 6b05001..d6c1fd0 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,40 @@ -github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= -github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/johnnybubonic/go-chattr v0.0.0-20240126141003-459f46177b13 h1:tgEbuE4bNVjaCWWIB1u9lDzGqH/ZdBTg33+4vNW2rjg= github.com/johnnybubonic/go-chattr v0.0.0-20240126141003-459f46177b13/go.mod h1:yQc6VPJfpDDC1g+W2t47+yYmzBNioax/GLiyJ25/IOs= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -r00t2.io/goutils v1.4.0 h1:/x/etLpMFv3+j1aPtT7KK2G0uOk+gQkGvXIYBCdjn3E= -r00t2.io/goutils v1.4.0/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= -r00t2.io/goutils v1.5.0 h1:haVk+wUK1BAk8f4UFGjy3ov3DwGMauZAOv/XYdb9isQ= -r00t2.io/goutils v1.5.0/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 h1:FW42yWB1sGClqswyHIB68wo0+oPrav1IuQ+Tdy8Qp8E= +honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8/go.mod h1:44w9OfBSQ9l3o59rc2w3AnABtE44bmtNnRMNC7z+oKE= r00t2.io/goutils v1.6.0 h1:oBC6PgBv0y/fdHeCmWgORHpBiU8uWw7IfFQJX5rIuzY= r00t2.io/goutils v1.6.0/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= r00t2.io/sysutils v1.1.1/go.mod h1:Wlfi1rrJpoKBOjWiYM9rw2FaiZqraD6VpXyiHgoDo/o=