diff --git a/app/cli/cmd/config_save.go b/app/cli/cmd/config_save.go index 25851ecaf..fd3210a4b 100644 --- a/app/cli/cmd/config_save.go +++ b/app/cli/cmd/config_save.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -29,6 +29,13 @@ func newConfigSaveCmd() *cobra.Command { skipActionOptsInit: trueString, }, RunE: func(cmd *cobra.Command, args []string) error { + // Process CA flags - read file contents and encode to base64 if needed + if err := processCAFlag(confOptions.controlplaneCA); err != nil { + return err + } + if err := processCAFlag(confOptions.CASCA); err != nil { + return err + } return viper.WriteConfig() }, } diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index ab4d2a6b8..937b76084 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -123,8 +123,13 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { grpcconn.WithInsecure(apiInsecure()), } - if caFilePath := viper.GetString(confOptions.controlplaneCA.viperKey); caFilePath != "" { - opts = append(opts, grpcconn.WithCAFile(caFilePath)) + if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" { + // Check if the value is a file path, if it is we read the content and encode it to base64, if not we assume it's the content already + if _, err := os.Stat(caValue); err == nil { + opts = append(opts, grpcconn.WithCAFile(caValue)) + } else { + opts = append(opts, grpcconn.WithCAContent(caValue)) + } } controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey) @@ -495,3 +500,24 @@ func isAPITokenPreferred(cmd *cobra.Command) bool { func getConfigDir(appName string) string { return filepath.Join(xdg.ConfigHome, appName) } + +// processCAFlag reads CA file content and stores it as PEM if value is a file path +func processCAFlag(opt *confOpt) error { + value := viper.GetString(opt.viperKey) + if value == "" { + return nil + } + + // If it's a file path, read and store PEM content directly + if _, err := os.Stat(value); err == nil { + content, err := os.ReadFile(value) + if err != nil { + return fmt.Errorf("failed to read CA file %s: %w", value, err) + } + + // Store PEM content directly + viper.Set(opt.viperKey, string(content)) + } + + return nil +} diff --git a/app/cli/pkg/action/action.go b/app/cli/pkg/action/action.go index 55225bbdf..aef2d9707 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -142,7 +142,12 @@ func getCASBackend(ctx context.Context, client pb.AttestationServiceClient, work opts := []grpcconn.Option{grpcconn.WithInsecure(casConnectionInsecure)} if casCAPath != "" { - opts = append(opts, grpcconn.WithCAFile(casCAPath)) + // Check if it's a file path or content. If it's a file path, it should exist. If not, treat it as content. + if _, err := os.Stat(casCAPath); err == nil { + opts = append(opts, grpcconn.WithCAFile(casCAPath)) + } else { + opts = append(opts, grpcconn.WithCAContent(casCAPath)) + } } artifactCASConn, err := grpcconn.New(casURI, result.Token, opts...) diff --git a/pkg/grpcconn/grpcconn.go b/pkg/grpcconn/grpcconn.go index d72010589..ff73febf6 100644 --- a/pkg/grpcconn/grpcconn.go +++ b/pkg/grpcconn/grpcconn.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package grpcconn import ( "context" "crypto/x509" + "encoding/base64" "fmt" "os" @@ -29,6 +30,7 @@ import ( type newOptionalArg struct { caFilePath string + caContent string insecure bool orgName string } @@ -41,6 +43,13 @@ func WithCAFile(caFilePath string) Option { } } +// WithCAContent sets the CA certificate content (PEM format or base64-encoded) +func WithCAContent(content string) Option { + return func(opt *newOptionalArg) { + opt.caContent = content + } +} + func WithInsecure(insecure bool) Option { return func(opt *newOptionalArg) { opt.insecure = insecure @@ -83,7 +92,13 @@ func New(uri, authToken string, opt ...Option) (*grpc.ClientConn, error) { return nil, err } - if optionalArgs.caFilePath != "" { + // Load CA from content if provided (takes precedence) + if optionalArgs.caContent != "" { + if err = appendCAFromContent(optionalArgs.caContent, certsPool); err != nil { + return nil, fmt.Errorf("failed to load CA from content: %w", err) + } + } else if optionalArgs.caFilePath != "" { + // Fallback to file path for backward compatibility if err = appendCAFromFile(optionalArgs.caFilePath, certsPool); err != nil { return nil, fmt.Errorf("failed to load CA cert: %w", err) } @@ -116,6 +131,27 @@ func appendCAFromFile(path string, certsPool *x509.CertPool) error { return nil } +func appendCAFromContent(content string, certsPool *x509.CertPool) error { + var pemContent []byte + + // Try to decode as base64 first + decoded, err := base64.StdEncoding.DecodeString(content) + if err == nil && len(decoded) > 0 { + // Successfully decoded as base64 + pemContent = decoded + } else { + // Not base64, assume it's PEM content directly + pemContent = []byte(content) + } + + // Append to cert pool + if ok := certsPool.AppendCertsFromPEM(pemContent); !ok { + return fmt.Errorf("failed to append CA cert to pool") + } + + return nil +} + type tokenAuth struct { token string insecure bool diff --git a/pkg/grpcconn/grpcconn_test.go b/pkg/grpcconn/grpcconn_test.go index e41ca03fc..137f8c68d 100644 --- a/pkg/grpcconn/grpcconn_test.go +++ b/pkg/grpcconn/grpcconn_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,11 +17,17 @@ package grpcconn import ( "context" + "crypto/x509" + "encoding/base64" + "os" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +const caPath = "../../devel/devkeys/selfsigned/rootCA.crt" + func TestGetRequestMetadata(t *testing.T) { const wantOrg = "org-1" want := map[string]string{"authorization": "Bearer token", "Chainloop-Organization": wantOrg} @@ -46,3 +52,169 @@ func TestRequireTransportSecurity(t *testing.T) { assert.Equal(t, tc.want, auth.RequireTransportSecurity()) } } + +func TestAppendCAFromFile(t *testing.T) { + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + err = appendCAFromFile(caPath, certsPool) + assert.NoError(t, err) +} + +func TestAppendCAFromFile_NonExistent(t *testing.T) { + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + err = appendCAFromFile("/nonexistent/ca.pem", certsPool) + assert.Error(t, err) +} + +func TestAppendCAFromContent_PEM(t *testing.T) { + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + // Read the PEM content + pemContent, err := os.ReadFile(caPath) + require.NoError(t, err) + + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + // Test with raw PEM content + err = appendCAFromContent(string(pemContent), certsPool) + assert.NoError(t, err) +} + +func TestAppendCAFromContent_Base64(t *testing.T) { + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + // Read the PEM content and encode as base64 + pemContent, err := os.ReadFile(caPath) + require.NoError(t, err) + base64Content := base64.StdEncoding.EncodeToString(pemContent) + + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + // Test with base64-encoded content + err = appendCAFromContent(base64Content, certsPool) + assert.NoError(t, err) +} + +func TestAppendCAFromContent_Invalid(t *testing.T) { + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + // Test with invalid content + err = appendCAFromContent("invalid certificate content", certsPool) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to append CA cert to pool") +} + +func TestWithCAFile(t *testing.T) { + opt := &newOptionalArg{} + WithCAFile("/path/to/ca.pem")(opt) + assert.Equal(t, "/path/to/ca.pem", opt.caFilePath) +} + +func TestWithCAContent(t *testing.T) { + opt := &newOptionalArg{} + testContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----" + WithCAContent(testContent)(opt) + assert.Equal(t, testContent, opt.caContent) +} + +func TestBackwardCompatibility_StoredFilePath(t *testing.T) { + // This test verifies that if a user has an old config with a stored file path, + // the new code will still load it correctly via the file path method. + + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + // Simulate an old config with a stored file path + storedValue := caPath + + // Verify IsFilePath detects it as a file path + _, err := os.Stat(storedValue) + assert.Nil(t, err, "stored file path should be detected as a file path") + + // Verify it can be loaded using the file path method + certsPool, err := x509.SystemCertPool() + require.NoError(t, err) + + err = appendCAFromFile(storedValue, certsPool) + assert.NoError(t, err, "should successfully load CA from stored file path") +} + +func TestBackwardCompatibility_NewClientOldConfig(t *testing.T) { + // This test verifies the complete flow: new client reading old config with file path + + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + // Simulate config value (could be file path or content) + oldConfigValue := caPath + + // New client logic: detect and load appropriately + var opts []Option + if _, err := os.Stat(oldConfigValue); err == nil { + opts = append(opts, WithCAFile(oldConfigValue)) + } else { + opts = append(opts, WithCAContent(oldConfigValue)) + } + + // Verify the correct option was chosen + require.Len(t, opts, 1) + + // Apply the option and verify it set caFilePath (not caContent) + optArg := &newOptionalArg{} + opts[0](optArg) + assert.Equal(t, caPath, optArg.caFilePath, "should use file path method for old config") + assert.Empty(t, optArg.caContent, "should not use content method for old config") +} + +func TestBackwardCompatibility_OldClientNewConfig(t *testing.T) { + // This test verifies that if a path is stored in config, both old and new + // clients can load it. Old clients would directly use WithCAFile, new clients + // would detect it via IsFilePath and use WithCAFile. + + // Check if the file exists, skip test if not + if _, err := os.Stat(caPath); os.IsNotExist(err) { + t.Skip("Test CA file not found, skipping test") + } + + // Stored config value (file path) + configValue := caPath + + certsPool1, err := x509.SystemCertPool() + require.NoError(t, err) + + certsPool2, err := x509.SystemCertPool() + require.NoError(t, err) + + // Old client behavior: directly use file path + err = appendCAFromFile(configValue, certsPool1) + assert.NoError(t, err, "old client should load file path") + + // New client behavior: detect then use file path + if _, statErr := os.Stat(configValue); statErr == nil { + err = appendCAFromFile(configValue, certsPool2) + } else { + err = appendCAFromContent(configValue, certsPool2) + } + assert.NoError(t, err, "new client should load file path via detection") +}