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