github.com/argoproj/argo-cd/v3@v3.2.1/util/cert/cert.go (about)

     1  // Utility functions for managing HTTPS server certificates and SSH known host
     2  // entries for ArgoCD
     3  package cert
     4  
     5  import (
     6  	"bufio"
     7  	"crypto/sha256"
     8  	"crypto/x509"
     9  	"encoding/base64"
    10  	"encoding/pem"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"path/filepath"
    16  	"regexp"
    17  	"strings"
    18  
    19  	log "github.com/sirupsen/logrus"
    20  	"golang.org/x/crypto/ssh"
    21  
    22  	"github.com/argoproj/argo-cd/v3/common"
    23  )
    24  
    25  // A struct representing an entry in the list of SSH known hosts.
    26  type SSHKnownHostsEntry struct {
    27  	// Hostname the key is for
    28  	Host string
    29  	// The type of the key
    30  	SubType string
    31  	// The data of the key, including the type
    32  	Data string
    33  	// The SHA256 fingerprint of the key
    34  	Fingerprint string
    35  }
    36  
    37  // A representation of a TLS certificate
    38  type TLSCertificate struct {
    39  	// Subject of the certificate
    40  	Subject string
    41  	// Issuer of the certificate
    42  	Issuer string
    43  	// Certificate data
    44  	Data string
    45  }
    46  
    47  // Helper struct for certificate selection
    48  type CertificateListSelector struct {
    49  	// Pattern to match the hostname with
    50  	HostNamePattern string
    51  	// Type of certificate to match
    52  	CertType string
    53  	// Subtype of certificate to match
    54  	CertSubType string
    55  }
    56  
    57  const (
    58  	// Text marker indicating start of certificate in PEM format
    59  	CertificateBeginMarker = "-----BEGIN CERTIFICATE-----"
    60  	// Text marker indicating end of certificate in PEM format
    61  	CertificateEndMarker = "-----END CERTIFICATE-----"
    62  	// Maximum number of lines for a single certificate
    63  	CertificateMaxLines = 128
    64  	// Maximum number of certificates or known host entries in a stream
    65  	CertificateMaxEntriesPerStream = 256
    66  )
    67  
    68  // Regular expression that matches a valid hostname
    69  var validHostNameRegexp = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9_][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])(\.([a-zA-Z0-9]|[a-zA-Z0-9_][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]))*(\.){0,1}$`)
    70  
    71  // Regular expression that matches all kind of IPv6 addresses
    72  // See https://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
    73  var validIPv6Regexp = regexp.MustCompile(`(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`)
    74  
    75  // Regular expression that matches a valid FQDN
    76  var validFQDNRegexp = regexp.MustCompile(`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*(\.){1}$`)
    77  
    78  // Can be used to test whether a given string represents a valid hostname
    79  // If fqdn is true, given string must also be a FQDN representation.
    80  func IsValidHostname(hostname string, fqdn bool) bool {
    81  	if !fqdn {
    82  		return validHostNameRegexp.MatchString(hostname) || validIPv6Regexp.MatchString(hostname)
    83  	}
    84  	return validFQDNRegexp.MatchString(hostname)
    85  }
    86  
    87  // Get the configured path to where TLS certificates are stored on the local
    88  // filesystem. If ARGOCD_TLS_DATA_PATH environment is set, path is taken from
    89  // there, otherwise the default will be returned.
    90  func GetTLSCertificateDataPath() string {
    91  	if envPath := os.Getenv(common.EnvVarTLSDataPath); envPath != "" {
    92  		return envPath
    93  	}
    94  	return common.DefaultPathTLSConfig
    95  }
    96  
    97  // Get the configured path to where SSH certificates are stored on the local
    98  // filesystem. If ARGOCD_SSH_DATA_PATH environment is set, path is taken from
    99  // there, otherwise the default will be returned.
   100  func GetSSHKnownHostsDataPath() string {
   101  	if envPath := os.Getenv(common.EnvVarSSHDataPath); envPath != "" {
   102  		return filepath.Join(envPath, common.DefaultSSHKnownHostsName)
   103  	}
   104  	return filepath.Join(common.DefaultPathSSHConfig, common.DefaultSSHKnownHostsName)
   105  }
   106  
   107  // Decode a certificate in PEM format to X509 data structure
   108  func DecodePEMCertificateToX509(pemData string) (*x509.Certificate, error) {
   109  	decodedData, _ := pem.Decode([]byte(pemData))
   110  	if decodedData == nil {
   111  		return nil, errors.New("could not decode PEM data from input")
   112  	}
   113  	x509Cert, err := x509.ParseCertificate(decodedData.Bytes)
   114  	if err != nil {
   115  		return nil, errors.New("could not parse X509 data from input")
   116  	}
   117  	return x509Cert, nil
   118  }
   119  
   120  // Parse TLS certificates from a multiline string
   121  func ParseTLSCertificatesFromData(data string) ([]string, error) {
   122  	return ParseTLSCertificatesFromStream(strings.NewReader(data))
   123  }
   124  
   125  // Parse TLS certificates from a file
   126  func ParseTLSCertificatesFromPath(sourceFile string) ([]string, error) {
   127  	fileHandle, err := os.Open(sourceFile)
   128  	if err != nil {
   129  		return nil, err
   130  	}
   131  	defer func() {
   132  		if err = fileHandle.Close(); err != nil {
   133  			log.WithFields(log.Fields{
   134  				common.SecurityField:    common.SecurityMedium,
   135  				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
   136  			}).Errorf("error closing file %q: %v", fileHandle.Name(), err)
   137  		}
   138  	}()
   139  	return ParseTLSCertificatesFromStream(fileHandle)
   140  }
   141  
   142  // Parse TLS certificate data from a data stream. The stream may contain more
   143  // than one certificate. Each found certificate will generate a unique entry
   144  // in the returned slice, so the length of the slice indicates how many
   145  // certificates have been found.
   146  func ParseTLSCertificatesFromStream(stream io.Reader) ([]string, error) {
   147  	scanner := bufio.NewScanner(stream)
   148  	inCertData := false
   149  	pemData := ""
   150  	curLine := 0
   151  	certLine := 0
   152  
   153  	certificateList := make([]string, 0)
   154  
   155  	// TODO: Implement maximum amount of data to parse
   156  	// TODO: Implement error heuristics
   157  
   158  	for scanner.Scan() {
   159  		curLine++
   160  		if !inCertData {
   161  			if strings.HasPrefix(scanner.Text(), CertificateBeginMarker) {
   162  				certLine = 1
   163  				inCertData = true
   164  				pemData += scanner.Text() + "\n"
   165  			}
   166  		} else {
   167  			certLine++
   168  			pemData += scanner.Text() + "\n"
   169  			if strings.HasPrefix(scanner.Text(), CertificateEndMarker) {
   170  				inCertData = false
   171  				certificateList = append(certificateList, pemData)
   172  				pemData = ""
   173  			}
   174  		}
   175  
   176  		if certLine > CertificateMaxLines {
   177  			return nil, errors.New("maximum number of lines exceeded during certificate parsing")
   178  		}
   179  	}
   180  
   181  	return certificateList, nil
   182  }
   183  
   184  // Parse SSH Known Hosts data from a multiline string
   185  func ParseSSHKnownHostsFromData(data string) ([]string, error) {
   186  	return ParseSSHKnownHostsFromStream(strings.NewReader(data))
   187  }
   188  
   189  // Parse SSH Known Hosts data from a file
   190  func ParseSSHKnownHostsFromPath(sourceFile string) ([]string, error) {
   191  	fileHandle, err := os.Open(sourceFile)
   192  	if err != nil {
   193  		return nil, err
   194  	}
   195  	defer func() {
   196  		if err = fileHandle.Close(); err != nil {
   197  			log.WithFields(log.Fields{
   198  				common.SecurityField:    common.SecurityMedium,
   199  				common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
   200  			}).Errorf("error closing file %q: %v", fileHandle.Name(), err)
   201  		}
   202  	}()
   203  	return ParseSSHKnownHostsFromStream(fileHandle)
   204  }
   205  
   206  // Parses a list of strings in SSH's known host data format from a stream and
   207  // returns the valid entries in an array.
   208  func ParseSSHKnownHostsFromStream(stream io.Reader) ([]string, error) {
   209  	scanner := bufio.NewScanner(stream)
   210  	knownHostsLists := make([]string, 0)
   211  	curLine := 0
   212  	numEntries := 0
   213  
   214  	for scanner.Scan() {
   215  		curLine++
   216  		lineData := scanner.Text()
   217  		if IsValidSSHKnownHostsEntry(lineData) {
   218  			numEntries++
   219  			knownHostsLists = append(knownHostsLists, lineData)
   220  		}
   221  	}
   222  
   223  	return knownHostsLists, nil
   224  }
   225  
   226  // Checks whether we can use a line from ssh_known_hosts data as an actual data
   227  // source for a RepoCertificate object. This function only checks for syntactic
   228  // validity, not if the data in the line is valid.
   229  func IsValidSSHKnownHostsEntry(line string) bool {
   230  	trimmedEntry := strings.TrimSpace(line)
   231  	// We ignore commented out lines - usually happens when copy and pasting
   232  	// to the ConfigMap from a known_hosts file or from ssh-keyscan output.
   233  	if trimmedEntry == "" || trimmedEntry[0] == '#' {
   234  		return false
   235  	}
   236  
   237  	// Each line should consist of three fields: host, type, data
   238  	keyData := strings.SplitN(trimmedEntry, " ", 3)
   239  	return len(keyData) == 3
   240  }
   241  
   242  // Tokenize a known_hosts entry into hostname, key sub type and actual key data
   243  func TokenizeSSHKnownHostsEntry(knownHostsEntry string) (string, string, []byte, error) {
   244  	knownHostsToken := strings.SplitN(knownHostsEntry, " ", 3)
   245  	if len(knownHostsToken) != 3 {
   246  		return "", "", nil, errors.New("error while tokenizing input data")
   247  	}
   248  	return knownHostsToken[0], knownHostsToken[1], []byte(knownHostsToken[2]), nil
   249  }
   250  
   251  // Parse a raw known hosts line into a PublicKey object and a list of hosts the
   252  // key would be valid for.
   253  func KnownHostsLineToPublicKey(line string) ([]string, ssh.PublicKey, error) {
   254  	_, hostnames, keyData, _, _, err := ssh.ParseKnownHosts([]byte(line))
   255  	if err != nil {
   256  		return nil, nil, err
   257  	}
   258  	return hostnames, keyData, nil
   259  }
   260  
   261  func TokenizedDataToPublicKey(hostname string, subType string, rawKeyData string) ([]string, ssh.PublicKey, error) {
   262  	hostnames, keyData, err := KnownHostsLineToPublicKey(fmt.Sprintf("%s %s %s", hostname, subType, rawKeyData))
   263  	if err != nil {
   264  		return nil, nil, err
   265  	}
   266  	return hostnames, keyData, nil
   267  }
   268  
   269  // Returns the requested pattern with all possible square brackets escaped
   270  func nonBracketedPattern(pattern string) string {
   271  	ret := strings.ReplaceAll(pattern, "[", `\[`)
   272  	return strings.ReplaceAll(ret, "]", `\]`)
   273  }
   274  
   275  // We do not use full fledged regular expression for matching the hostname.
   276  // Instead, we use a less expensive file system glob, which should be fully
   277  // sufficient for our use case.
   278  func MatchHostName(hostname, pattern string) bool {
   279  	// If pattern is empty, we always return a match
   280  	if pattern == "" {
   281  		return true
   282  	}
   283  	match, err := filepath.Match(nonBracketedPattern(pattern), hostname)
   284  	if err != nil {
   285  		return false
   286  	}
   287  	return match
   288  }
   289  
   290  // Convenience wrapper around SSHFingerprintSHA256
   291  func SSHFingerprintSHA256FromString(key string) string {
   292  	pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
   293  	if err != nil {
   294  		return ""
   295  	}
   296  	return SSHFingerprintSHA256(pubKey)
   297  }
   298  
   299  // base64 sha256 hash with the trailing equal sign removed
   300  func SSHFingerprintSHA256(key ssh.PublicKey) string {
   301  	hash := sha256.Sum256(key.Marshal())
   302  	b64hash := base64.StdEncoding.EncodeToString(hash[:])
   303  	return strings.TrimRight(b64hash, "=")
   304  }
   305  
   306  // Remove possible port number from hostname and return just the FQDN
   307  func ServerNameWithoutPort(serverName string) string {
   308  	return strings.Split(serverName, ":")[0]
   309  }
   310  
   311  // Load certificate data from a file. If the file does not exist, we do not
   312  // consider it an error and just return empty data.
   313  func GetCertificateForConnect(serverName string) ([]string, error) {
   314  	dataPath := GetTLSCertificateDataPath()
   315  	certPath, err := filepath.Abs(filepath.Join(dataPath, ServerNameWithoutPort(serverName)))
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  	if !strings.HasPrefix(certPath, dataPath) {
   320  		return nil, fmt.Errorf("could not get certificate for host %s", serverName)
   321  	}
   322  	certificates, err := ParseTLSCertificatesFromPath(certPath)
   323  	if err != nil {
   324  		if os.IsNotExist(err) {
   325  			return nil, nil
   326  		}
   327  		return nil, err
   328  	}
   329  
   330  	if len(certificates) == 0 {
   331  		return nil, errors.New("no certificates found in existing file")
   332  	}
   333  
   334  	return certificates, nil
   335  }
   336  
   337  // Gets the full path for a certificate bundle configured from a ConfigMap
   338  // mount. This function makes sure that the path returned actually contain
   339  // at least one valid certificate, and no invalid data.
   340  func GetCertBundlePathForRepository(serverName string) (string, error) {
   341  	certPath := filepath.Join(GetTLSCertificateDataPath(), ServerNameWithoutPort(serverName))
   342  	certs, err := GetCertificateForConnect(serverName)
   343  	if err != nil {
   344  		return "", nil
   345  	}
   346  	if len(certs) == 0 {
   347  		return "", nil
   348  	}
   349  	return certPath, nil
   350  }
   351  
   352  // Convert a list of certificates in PEM format to a x509.CertPool object,
   353  // usable for most golang TLS functions.
   354  func GetCertPoolFromPEMData(pemData []string) *x509.CertPool {
   355  	certPool := x509.NewCertPool()
   356  	for _, pem := range pemData {
   357  		certPool.AppendCertsFromPEM([]byte(pem))
   358  	}
   359  	return certPool
   360  }