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 }