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 }