Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 69 additions & 32 deletions cmd/argocd/commands/cert.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package commands

import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
stderrors "errors"
"fmt"
"os"
Expand Down Expand Up @@ -62,6 +64,7 @@ func NewCertAddTLSCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command
fromFile string
upsert bool
)

command := &cobra.Command{
Use: "add-tls SERVERNAME",
Short: "Add TLS certificate data for connecting to repository server SERVERNAME",
Expand All @@ -76,8 +79,10 @@ func NewCertAddTLSCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command
os.Exit(1)
}

var certificateArray []string
var err error
var (
certificateArray []string
err error
)

if fromFile != "" {
fmt.Printf("Reading TLS certificate data in PEM format from '%s'\n", fromFile)
Expand All @@ -86,56 +91,88 @@ func NewCertAddTLSCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command
fmt.Println("Enter TLS certificate data in PEM format. Press CTRL-D when finished.")
certificateArray, err = certutil.ParseTLSCertificatesFromStream(os.Stdin)
}

errors.CheckError(err)

certificateList := make([]appsv1.RepositoryCertificate, 0)

subjectMap := make(map[string]*x509.Certificate)

for _, entry := range certificateArray {
// We want to make sure to only send valid certificate data to the
// server, so we decode the certificate into X509 structure before
// further processing it.
x509cert, err := certutil.DecodePEMCertificateToX509(entry)
errors.CheckError(err)
uniqueCerts, err := deduplicatePEMCertificates(certificateArray)
errors.CheckError(err)

// TODO: We need a better way to detect duplicates sent in the stream,
// maybe by using fingerprints? For now, no two certs with the same
// subject may be sent.
if subjectMap[x509cert.Subject.String()] != nil {
fmt.Printf("ERROR: Cert with subject '%s' already seen in the input stream.\n", x509cert.Subject.String())
continue
}
subjectMap[x509cert.Subject.String()] = x509cert
if len(uniqueCerts) == 0 {
fmt.Println("No valid certificates have been detected in the stream.")
return
}

serverName := args[0]

if len(certificateArray) > 0 {
certificateList = append(certificateList, appsv1.RepositoryCertificate{
certificateList := []appsv1.RepositoryCertificate{
{
ServerName: serverName,
CertType: "https",
CertData: []byte(strings.Join(certificateArray, "\n")),
})
certificates, err := certIf.CreateCertificate(ctx, &certificatepkg.RepositoryCertificateCreateRequest{
CertData: []byte(strings.Join(uniqueCerts, "\n")),
},
}

_, err = certIf.CreateCertificate(
ctx,
&certificatepkg.RepositoryCertificateCreateRequest{
Certificates: &appsv1.RepositoryCertificateList{
Items: certificateList,
},
Upsert: upsert,
})
errors.CheckError(err)
fmt.Printf("Created entry with %d PEM certificates for repository server %s\n", len(certificates.Items), serverName)
} else {
fmt.Printf("No valid certificates have been detected in the stream.\n")
}
},
)
errors.CheckError(err)

fmt.Printf(
"Created/updated TLS certificate entry for repository server %s with %d unique PEM certificates\n",
serverName,
len(uniqueCerts),
)
},
}
command.Flags().StringVar(&fromFile, "from", "", "Read TLS certificate data from file (default is to read from stdin)")
command.Flags().BoolVar(&upsert, "upsert", false, "Replace existing TLS certificate if certificate is different in input")
return command
}

// certFingerprintSHA256 returns the SHA256 fingerprint of the given X.509 certificate.
// The fingerprint is returned as a lowercase hexadecimal string.
func certFingerprintSHA256(cert *x509.Certificate) string {
sum := sha256.Sum256(cert.Raw)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a question. Using cert.Raw treats certificate renewals with the same public key as distinct certificates. Is this intentional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is intentional. The goal of this change is to deduplicate identical certificate data, not to collapse logically related certificates.

return hex.EncodeToString(sum[:])
}

// deduplicatePEMCertificates removes duplicate PEM certificates from the input slice.
// Two certificates are considered duplicates if their SHA256 fingerprints match.
// The function returns a slice of unique certificates in the original order.
// If any certificate cannot be decoded into X.509 format, an error is returned.
func deduplicatePEMCertificates(pems []string) ([]string, error) {
fingerprintMap := make(map[string]struct{})
uniqueCerts := make([]string, 0)

for _, entry := range pems {
x509cert, err := certutil.DecodePEMCertificateToX509(entry)
if err != nil {
return nil, err
}

fingerprint := certFingerprintSHA256(x509cert)

if _, exists := fingerprintMap[fingerprint]; exists {
fmt.Printf(
"WARNING: Duplicate certificate detected (SHA256 fingerprint %s, subject '%s'), skipping.\n",
fingerprint,
x509cert.Subject.String(),
)
continue
}

fingerprintMap[fingerprint] = struct{}{}
uniqueCerts = append(uniqueCerts, entry)
}

return uniqueCerts, nil
}

// NewCertAddSSHCommand returns a new instance of an `argocd cert add` command
func NewCertAddSSHCommand(clientOpts *argocdclient.ClientOptions) *cobra.Command {
var (
Expand Down
77 changes: 77 additions & 0 deletions cmd/argocd/commands/cert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package commands

import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func generateTestCert(t *testing.T, cn string) string {
t.Helper()

priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)

template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: cn,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}

der, err := x509.CreateCertificate(
rand.Reader,
template,
template,
&priv.PublicKey,
priv,
)
require.NoError(t, err)

var buf bytes.Buffer
require.NoError(t, pem.Encode(&buf, &pem.Block{
Type: "CERTIFICATE",
Bytes: der,
}))

return buf.String()
}

func TestDeduplicatePEMCertificates_DuplicateCerts(t *testing.T) {
cert := generateTestCert(t, "repo.example.com")

pems := []string{cert, cert}

unique, err := deduplicatePEMCertificates(pems)
require.NoError(t, err)
require.Len(t, unique, 1)
}

func TestDeduplicatePEMCertificates_SameSubjectDifferentCerts(t *testing.T) {
cert1 := generateTestCert(t, "repo.example.com")
cert2 := generateTestCert(t, "repo.example.com")

pems := []string{cert1, cert2}

unique, err := deduplicatePEMCertificates(pems)
require.NoError(t, err)
require.Len(t, unique, 2)
}

func TestDeduplicatePEMCertificates_InvalidCert(t *testing.T) {
pems := []string{"not a cert"}

_, err := deduplicatePEMCertificates(pems)
require.Error(t, err)
}
Loading