Skip to content

Commit 88b337c

Browse files
committed
feat(cli): support inline CA certificates in gRPC connections
Store CA certificate content (base64-encoded) in config instead of file paths, enabling portable configurations across environments. The gRPC connection layer now accepts CA content directly via WithCAContent option. Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent afa7c8c commit 88b337c

4 files changed

Lines changed: 104 additions & 4 deletions

File tree

app/cli/cmd/config_save.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ func newConfigSaveCmd() *cobra.Command {
2929
skipActionOptsInit: trueString,
3030
},
3131
RunE: func(cmd *cobra.Command, args []string) error {
32+
// Process CA flags - read file contents and encode to base64 if needed
33+
processCAFlag(confOptions.controlplaneCA)
34+
processCAFlag(confOptions.CASCA)
3235
return viper.WriteConfig()
3336
},
3437
}

app/cli/cmd/root.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package cmd
1818
import (
1919
"context"
2020
"crypto/sha256"
21+
"encoding/base64"
2122
"errors"
2223
"fmt"
2324
"os"
@@ -123,8 +124,13 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
123124
grpcconn.WithInsecure(apiInsecure()),
124125
}
125126

126-
if caFilePath := viper.GetString(confOptions.controlplaneCA.viperKey); caFilePath != "" {
127-
opts = append(opts, grpcconn.WithCAFile(caFilePath))
127+
if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" {
128+
// Check if it's a file path or base64/PEM content
129+
if grpcconn.IsFilePath(caValue) {
130+
opts = append(opts, grpcconn.WithCAFile(caValue))
131+
} else {
132+
opts = append(opts, grpcconn.WithCAContent(caValue))
133+
}
128134
}
129135

130136
controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)
@@ -495,3 +501,25 @@ func isAPITokenPreferred(cmd *cobra.Command) bool {
495501
func getConfigDir(appName string) string {
496502
return filepath.Join(xdg.ConfigHome, appName)
497503
}
504+
505+
// processCAFlag reads CA file content and encodes it to base64 if value is a file path
506+
func processCAFlag(opt *confOpt) {
507+
value := viper.GetString(opt.viperKey)
508+
if value == "" {
509+
return
510+
}
511+
512+
// If it's a file path, read and encode
513+
if grpcconn.IsFilePath(value) {
514+
content, err := os.ReadFile(value)
515+
if err != nil {
516+
// Log warning but don't fail
517+
logger.Warn().Err(err).Str("file", value).Msg("Failed to read CA file")
518+
return
519+
}
520+
521+
// Store base64-encoded content in viper (will be persisted by config save)
522+
encoded := base64.StdEncoding.EncodeToString(content)
523+
viper.Set(opt.viperKey, encoded)
524+
}
525+
}

app/cli/pkg/action/action.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ func getCASBackend(ctx context.Context, client pb.AttestationServiceClient, work
142142

143143
opts := []grpcconn.Option{grpcconn.WithInsecure(casConnectionInsecure)}
144144
if casCAPath != "" {
145-
opts = append(opts, grpcconn.WithCAFile(casCAPath))
145+
// Check if it's a file path or base64/PEM content
146+
if grpcconn.IsFilePath(casCAPath) {
147+
opts = append(opts, grpcconn.WithCAFile(casCAPath))
148+
} else {
149+
opts = append(opts, grpcconn.WithCAContent(casCAPath))
150+
}
146151
}
147152

148153
artifactCASConn, err := grpcconn.New(casURI, result.Token, opts...)

pkg/grpcconn/grpcconn.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ package grpcconn
1818
import (
1919
"context"
2020
"crypto/x509"
21+
"encoding/base64"
2122
"fmt"
2223
"os"
24+
"strings"
2325

2426
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
2527
"google.golang.org/grpc"
@@ -29,6 +31,7 @@ import (
2931

3032
type newOptionalArg struct {
3133
caFilePath string
34+
caContent string
3235
insecure bool
3336
orgName string
3437
}
@@ -41,6 +44,13 @@ func WithCAFile(caFilePath string) Option {
4144
}
4245
}
4346

47+
// WithCAContent sets the CA certificate content (PEM format or base64-encoded)
48+
func WithCAContent(content string) Option {
49+
return func(opt *newOptionalArg) {
50+
opt.caContent = content
51+
}
52+
}
53+
4454
func WithInsecure(insecure bool) Option {
4555
return func(opt *newOptionalArg) {
4656
opt.insecure = insecure
@@ -83,7 +93,13 @@ func New(uri, authToken string, opt ...Option) (*grpc.ClientConn, error) {
8393
return nil, err
8494
}
8595

86-
if optionalArgs.caFilePath != "" {
96+
// Load CA from content if provided (takes precedence)
97+
if optionalArgs.caContent != "" {
98+
if err = appendCAFromContent(optionalArgs.caContent, certsPool); err != nil {
99+
return nil, fmt.Errorf("failed to load CA from content: %w", err)
100+
}
101+
} else if optionalArgs.caFilePath != "" {
102+
// Fallback to file path for backward compatibility
87103
if err = appendCAFromFile(optionalArgs.caFilePath, certsPool); err != nil {
88104
return nil, fmt.Errorf("failed to load CA cert: %w", err)
89105
}
@@ -116,6 +132,27 @@ func appendCAFromFile(path string, certsPool *x509.CertPool) error {
116132
return nil
117133
}
118134

135+
func appendCAFromContent(content string, certsPool *x509.CertPool) error {
136+
var pemContent []byte
137+
138+
// Try to decode as base64 first
139+
decoded, err := base64.StdEncoding.DecodeString(content)
140+
if err == nil && len(decoded) > 0 {
141+
// Successfully decoded as base64
142+
pemContent = decoded
143+
} else {
144+
// Not base64, assume it's PEM content directly
145+
pemContent = []byte(content)
146+
}
147+
148+
// Append to cert pool
149+
if ok := certsPool.AppendCertsFromPEM(pemContent); !ok {
150+
return fmt.Errorf("failed to append CA cert to pool")
151+
}
152+
153+
return nil
154+
}
155+
119156
type tokenAuth struct {
120157
token string
121158
insecure bool
@@ -140,3 +177,30 @@ func (t tokenAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[strin
140177
func (t tokenAuth) RequireTransportSecurity() bool {
141178
return !t.insecure
142179
}
180+
181+
// IsFilePath checks if a value looks like a file path (vs base64/PEM content).
182+
// It returns true if the value appears to be a file path, false if it appears to be content.
183+
func IsFilePath(value string) bool {
184+
// Check if value looks like a file path
185+
// If it starts with /, ./, ../, or ~/, it's a path
186+
// If it contains a newline, it's likely PEM content
187+
// If it's valid base64 without path separators, assume it's content
188+
189+
if strings.HasPrefix(value, "/") ||
190+
strings.HasPrefix(value, "./") ||
191+
strings.HasPrefix(value, "../") ||
192+
strings.HasPrefix(value, "~/") {
193+
return true
194+
}
195+
196+
if strings.Contains(value, "\n") {
197+
return false // PEM content
198+
}
199+
200+
// Check if file exists
201+
if _, err := os.Stat(value); err == nil {
202+
return true
203+
}
204+
205+
return false
206+
}

0 commit comments

Comments
 (0)