argo-cd/util/db/certificate.go
Oliver Gondža 2ccc2ea466
chore(docs): Fix godoc in util/db/certificate.go (#27380)
Signed-off-by: Oliver Gondža <ogondza@gmail.com>
2026-04-16 12:01:19 +00:00

493 lines
16 KiB
Go

package db
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"golang.org/x/crypto/ssh"
log "github.com/sirupsen/logrus"
"github.com/argoproj/argo-cd/v3/common"
appsv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
certutil "github.com/argoproj/argo-cd/v3/util/cert"
)
// SSHKnownHostsEntry represents an entry in the list of SSH known hosts.
type SSHKnownHostsEntry struct {
// Hostname the key is for
Host string
// The type of the key
SubType string
// The data of the key, including the type
Data string
// The SHA256 fingerprint of the key
Fingerprint string
}
// TLSCertificate represents a TLS certificate.
type TLSCertificate struct {
// Subject of the certificate
Subject string
// Issuer of the certificate
Issuer string
// Certificate data
Data string
}
// CertificateListSelector is a helper struct for certificate selection.
type CertificateListSelector struct {
// Pattern to match the hostname with
HostNamePattern string
// Type of certificate to match
CertType string
// Subtype of certificate to match
CertSubType string
}
// ListRepoCertificates returns a list of all configured repository certificates matching the given
// selector. The list of certificates explicitly excludes the CertData of
// the certificates, and only returns the metadata including CertInfo field.
//
// The CertInfo field in the returned entries will contain the following data:
// - For SSH keys, the SHA256 fingerprint of the key as string, prepended by
// the string "SHA256:"
// - For TLS certs, the Subject of the X509 cert as a string in DN notation
func (db *db) ListRepoCertificates(_ context.Context, selector *CertificateListSelector) (*appsv1.RepositoryCertificateList, error) {
// selector may be given as nil, but we need at least an empty data structure,
// so we create it if necessary.
if selector == nil {
selector = &CertificateListSelector{}
}
certificates := make([]appsv1.RepositoryCertificate, 0)
// Get all SSH known host entries
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "ssh" {
sshKnownHosts, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
for _, entry := range sshKnownHosts {
if certutil.MatchHostName(entry.Host, selector.HostNamePattern) && (selector.CertSubType == "" || selector.CertSubType == "*" || selector.CertSubType == entry.SubType) {
certificates = append(certificates, appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertInfo: "SHA256:" + certutil.SSHFingerprintSHA256FromString(fmt.Sprintf("%s %s", entry.Host, entry.Data)),
})
}
}
}
// Get all TLS certificates
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "https" || selector.CertType == "tls" {
tlsCertificates, err := db.getTLSCertificateData()
if err != nil {
return nil, err
}
for _, entry := range tlsCertificates {
if certutil.MatchHostName(entry.Subject, selector.HostNamePattern) {
pemEntries, err := certutil.ParseTLSCertificatesFromData(entry.Data)
if err != nil {
continue
}
for _, pemEntry := range pemEntries {
var certInfo, certSubType string
x509Data, err := certutil.DecodePEMCertificateToX509(pemEntry)
if err != nil {
certInfo = err.Error()
certSubType = "invalid"
} else {
certInfo = x509Data.Subject.String()
certSubType = x509Data.PublicKeyAlgorithm.String()
}
certificates = append(certificates, appsv1.RepositoryCertificate{
ServerName: entry.Subject,
CertType: "https",
CertSubType: strings.ToLower(certSubType),
CertInfo: certInfo,
})
}
}
}
}
return &appsv1.RepositoryCertificateList{
Items: certificates,
}, nil
}
// GetRepoCertificate returns a single certificate from the datastore
func (db *db) GetRepoCertificate(_ context.Context, serverType string, serverName string) (*appsv1.RepositoryCertificate, error) {
if serverType == "ssh" {
sshKnownHostsList, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
for _, entry := range sshKnownHostsList {
if entry.Host == serverName {
repo := &appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertData: []byte(entry.Data),
CertInfo: entry.Fingerprint,
}
return repo, nil
}
}
}
// Fail
return nil, nil
}
// CreateRepoCertificate creates one or more repository certificates and returns a list of certificates
// actually created.
func (db *db) CreateRepoCertificate(ctx context.Context, certificates *appsv1.RepositoryCertificateList, upsert bool) (*appsv1.RepositoryCertificateList, error) {
var (
saveSSHData = false
saveTLSData = false
)
sshKnownHostsList, err := db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
tlsCertificates, err := db.getTLSCertificateData()
if err != nil {
return nil, err
}
// This will hold the final list of certificates that have been created
created := make([]appsv1.RepositoryCertificate, 0)
// Each request can contain multiple certificates of different types, so we
// make sure to handle each request accordingly.
for _, certificate := range certificates.Items {
// Ensure valid repo server name was given only for https certificates.
// For SSH known host entries, we let Go's ssh library do the validation
// later on.
if certificate.CertType == "https" && !certutil.IsValidHostname(certificate.ServerName, false) {
return nil, fmt.Errorf("invalid hostname in request: %s", certificate.ServerName)
} else if certificate.CertType == "ssh" {
// Matches "[hostname]:port" format
reExtract := regexp.MustCompile(`^\[(.*)]:\d+$`)
matches := reExtract.FindStringSubmatch(certificate.ServerName)
var hostnameToCheck string
if len(matches) == 0 {
hostnameToCheck = certificate.ServerName
} else {
hostnameToCheck = matches[1]
}
if !certutil.IsValidHostname(hostnameToCheck, false) {
return nil, fmt.Errorf("invalid hostname in request: %s", hostnameToCheck)
}
}
switch certificate.CertType {
case "ssh":
// Whether we have a new certificate entry
newEntry := true
// Whether we have upserted an existing certificate entry
upserted := false
// Check whether known hosts entry already exists. Must match hostname
// and the key sub type (e.g. ssh-rsa). It is considered an error if we
// already have a corresponding key and upsert was not specified.
for _, entry := range sshKnownHostsList {
if entry.Host == certificate.ServerName && entry.SubType == certificate.CertSubType {
if !upsert && entry.Data != string(certificate.CertData) {
return nil, fmt.Errorf("key for '%s' (subtype: '%s') already exists, and upsert was not specified", entry.Host, entry.SubType)
}
// Do not add an entry on upsert, but remember if we actually did an
// upsert.
newEntry = false
if entry.Data != string(certificate.CertData) {
entry.Data = string(certificate.CertData)
upserted = true
}
break
}
}
// Make sure that we received a valid public host key by parsing it
_, hostnames, rawKeyData, _, _, err := ssh.ParseKnownHosts(fmt.Appendf(nil, "%s %s %s", certificate.ServerName, certificate.CertSubType, certificate.CertData))
if err != nil {
return nil, err
}
if len(hostnames) == 0 {
log.Errorf("Could not parse hostname for key from token %s", certificate.ServerName)
}
if newEntry {
sshKnownHostsList = append(sshKnownHostsList, &SSHKnownHostsEntry{
Host: hostnames[0],
Data: string(certificate.CertData),
SubType: certificate.CertSubType,
})
}
// If we created a new entry, or if we upserted an existing one, we need
// to save the data and notify the consumer about the operation.
if newEntry || upserted {
certificate.CertInfo = certutil.SSHFingerprintSHA256(rawKeyData)
created = append(created, certificate)
saveSSHData = true
}
case "https":
var tlsCertificate *TLSCertificate
newEntry := true
upserted := false
pemCreated := make([]string, 0)
for _, entry := range tlsCertificates {
// We have an entry for this server already. Check for upsert.
if entry.Subject == certificate.ServerName {
newEntry = false
if entry.Data != string(certificate.CertData) {
if !upsert {
return nil, fmt.Errorf("TLS certificate for server '%s' already exists, and upsert was not specified", entry.Subject)
}
}
// Store pointer to this entry for later use.
tlsCertificate = entry
break
}
}
// Check for validity of data received
pemData, err := certutil.ParseTLSCertificatesFromData(string(certificate.CertData))
if err != nil {
return nil, err
}
// We should have at least one valid PEM entry
if len(pemData) == 0 {
return nil, errors.New("no valid PEM data received")
}
// Make sure we have valid X509 certificates in the data
for _, entry := range pemData {
_, err := certutil.DecodePEMCertificateToX509(entry)
if err != nil {
return nil, err
}
pemCreated = append(pemCreated, entry)
}
// New certificate if pointer to existing cert is nil
if tlsCertificate == nil {
tlsCertificate = &TLSCertificate{
Subject: certificate.ServerName,
Data: string(certificate.CertData),
}
tlsCertificates = append(tlsCertificates, tlsCertificate)
} else if tlsCertificate.Data != string(certificate.CertData) {
// We have made sure the upsert flag was set above. Now just figure out
// again if we have to actually update the data in the existing cert.
tlsCertificate.Data = string(certificate.CertData)
upserted = true
}
if newEntry || upserted {
// We append the certificate for every PEM entry in the request, so the
// caller knows that we processed each single item.
for _, entry := range pemCreated {
created = append(created, appsv1.RepositoryCertificate{
ServerName: certificate.ServerName,
CertType: "https",
CertData: []byte(entry),
})
}
saveTLSData = true
}
default:
// Invalid/unknown certificate type
return nil, fmt.Errorf("unknown certificate type: %s", certificate.CertType)
}
}
if saveSSHData {
err = db.settingsMgr.SaveSSHKnownHostsData(ctx, knownHostsDataToStrings(sshKnownHostsList))
if err != nil {
return nil, err
}
}
if saveTLSData {
err = db.settingsMgr.SaveTLSCertificateData(ctx, tlsCertificatesToMap(tlsCertificates))
if err != nil {
return nil, err
}
}
return &appsv1.RepositoryCertificateList{Items: created}, nil
}
// RemoveRepoCertificates removes configured certificates according to the selector query
func (db *db) RemoveRepoCertificates(ctx context.Context, selector *CertificateListSelector) (*appsv1.RepositoryCertificateList, error) {
var (
knownHostsOld []*SSHKnownHostsEntry
knownHostsNew []*SSHKnownHostsEntry
tlsCertificatesOld []*TLSCertificate
tlsCertificatesNew []*TLSCertificate
err error
)
removed := &appsv1.RepositoryCertificateList{
Items: make([]appsv1.RepositoryCertificate, 0),
}
if selector.CertType == "" || selector.CertType == "ssh" || selector.CertType == "*" {
knownHostsOld, err = db.getSSHKnownHostsData()
if err != nil {
return nil, err
}
knownHostsNew = make([]*SSHKnownHostsEntry, 0)
for _, entry := range knownHostsOld {
if matchSSHKnownHostsEntry(entry, selector) {
removed.Items = append(removed.Items, appsv1.RepositoryCertificate{
ServerName: entry.Host,
CertType: "ssh",
CertSubType: entry.SubType,
CertData: []byte(entry.Data),
})
} else {
knownHostsNew = append(knownHostsNew, entry)
}
}
}
if selector.CertType == "" || selector.CertType == "*" || selector.CertType == "https" || selector.CertType == "tls" {
tlsCertificatesOld, err = db.getTLSCertificateData()
if err != nil {
return nil, err
}
tlsCertificatesNew = make([]*TLSCertificate, 0)
for _, entry := range tlsCertificatesOld {
if certutil.MatchHostName(entry.Subject, selector.HostNamePattern) {
// Wrap each PEM certificate into its own RepositoryCertificate object
// so the caller knows what has actually been removed.
//
// The downside of this is, only valid data can be removed from the CM,
// so if the data somehow got corrupted, it can only be removed by
// means of editing the CM directly using e.g. kubectl.
pemCertificates, err := certutil.ParseTLSCertificatesFromData(entry.Data)
if err != nil {
return nil, err
}
if len(pemCertificates) > 0 {
for _, pem := range pemCertificates {
removed.Items = append(removed.Items, appsv1.RepositoryCertificate{
ServerName: entry.Subject,
CertType: "https",
CertData: []byte(pem),
})
}
}
} else {
tlsCertificatesNew = append(tlsCertificatesNew, entry)
}
}
}
if len(knownHostsNew) < len(knownHostsOld) {
err = db.settingsMgr.SaveSSHKnownHostsData(ctx, knownHostsDataToStrings(knownHostsNew))
if err != nil {
return nil, err
}
}
if len(tlsCertificatesNew) < len(tlsCertificatesOld) {
err = db.settingsMgr.SaveTLSCertificateData(ctx, tlsCertificatesToMap(tlsCertificatesNew))
if err != nil {
return nil, err
}
}
return removed, nil
}
// Converts list of known hosts data to array of strings, suitable for storing
// in a known_hosts file for SSH.
func knownHostsDataToStrings(knownHostsList []*SSHKnownHostsEntry) []string {
knownHostsData := make([]string, 0)
for _, entry := range knownHostsList {
knownHostsData = append(knownHostsData, fmt.Sprintf("%s %s %s", entry.Host, entry.SubType, entry.Data))
}
return knownHostsData
}
// Converts list of TLS certificates to a map whose key will be the certificate
// subject and the data will be a string containing TLS certificate data as PEM
func tlsCertificatesToMap(tlsCertificates []*TLSCertificate) map[string]string {
certMap := make(map[string]string)
for _, entry := range tlsCertificates {
certMap[entry.Subject] = entry.Data
}
return certMap
}
// Get the TLS certificate data from the config map
func (db *db) getTLSCertificateData() ([]*TLSCertificate, error) {
certificates := make([]*TLSCertificate, 0)
certCM, err := db.settingsMgr.GetConfigMapByName(common.ArgoCDTLSCertsConfigMapName)
if err != nil {
return nil, err
}
for key, entry := range certCM.Data {
certificates = append(certificates, &TLSCertificate{Subject: key, Data: entry})
}
return certificates, nil
}
// Gets the SSH known host data from ConfigMap and parse it into an array of
// SSHKnownHostEntry structs.
func (db *db) getSSHKnownHostsData() ([]*SSHKnownHostsEntry, error) {
certCM, err := db.settingsMgr.GetConfigMapByName(common.ArgoCDKnownHostsConfigMapName)
if err != nil {
return nil, err
}
sshKnownHostsData := certCM.Data["ssh_known_hosts"]
entries := make([]*SSHKnownHostsEntry, 0)
// ssh_known_hosts data contains one key per line, so we must iterate over
// the whole data to get all keys.
//
// We validate the data found to a certain extent before we accept them as
// entry into our list to be returned.
//
sshKnownHostsEntries, err := certutil.ParseSSHKnownHostsFromData(sshKnownHostsData)
if err != nil {
return nil, err
}
for _, entry := range sshKnownHostsEntries {
hostname, subType, keyData, err := certutil.TokenizeSSHKnownHostsEntry(entry)
if err != nil {
return nil, err
}
entries = append(entries, &SSHKnownHostsEntry{
Host: hostname,
SubType: subType,
Data: string(keyData),
})
}
return entries, nil
}
func matchSSHKnownHostsEntry(entry *SSHKnownHostsEntry, selector *CertificateListSelector) bool {
return certutil.MatchHostName(entry.Host, selector.HostNamePattern) && (selector.CertSubType == "" || selector.CertSubType == "*" || selector.CertSubType == entry.SubType)
}