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
9 changes: 8 additions & 1 deletion app/cli/cmd/config_save.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()
},
}
Expand Down
34 changes: 31 additions & 3 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,6 +18,7 @@ package cmd
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -123,8 +124,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)
Expand Down Expand Up @@ -495,3 +501,25 @@ func isAPITokenPreferred(cmd *cobra.Command) bool {
func getConfigDir(appName string) string {
return filepath.Join(xdg.ConfigHome, appName)
}

// processCAFlag reads CA file content and encodes it to base64 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 encode
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 base64-encoded content in viper (will be persisted by config save)
encoded := base64.StdEncoding.EncodeToString(content)
viper.Set(opt.viperKey, encoded)
}

return nil
}
9 changes: 7 additions & 2 deletions app/cli/pkg/action/action.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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...)
Expand Down
40 changes: 38 additions & 2 deletions pkg/grpcconn/grpcconn.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,6 +18,7 @@ package grpcconn
import (
"context"
"crypto/x509"
"encoding/base64"
"fmt"
"os"

Expand All @@ -29,6 +30,7 @@ import (

type newOptionalArg struct {
caFilePath string
caContent string
insecure bool
orgName string
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
174 changes: 173 additions & 1 deletion pkg/grpcconn/grpcconn_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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}
Expand All @@ -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")
}
Loading