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
71 changes: 71 additions & 0 deletions internal/certbootstrap/persist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package certbootstrap
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

add tests


import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
)

func ResolveJfrogHomeDir() string {
if v := os.Getenv("JFROG_CLI_HOME_DIR"); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ".jfrog"
}
return filepath.Join(home, ".jfrog")
}

func PersistVerifiedChain(jfrogHome string, chain []*x509.Certificate) (int, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why do we need to persist the chain ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Go on Windows can validate a certificate against the CAs available in the Windows certificate store (typically managed by a corporate PKI team).

If the connection is successfully validated by Go, we can safely extract and copy the certificate chain into the location expected by JFrog CLI (~/.jfrog/security/certs).

This approach avoids a full rewrite of the CLI to directly support the Windows certificate store. Instead, it preserves the existing mechanism (manual placement of private CA certificates), while leveraging Go’s ability to interact with the system certificate store to ensure the chain we import is trusted.

certsDir := filepath.Join(jfrogHome, "security", "certs")
if err := os.MkdirAll(certsDir, 0o700); err != nil {
return 0, err
}

fmt.Fprintf(os.Stderr, "[certbootstrap] persisting to cert dir=%s\n", certsDir)

written := 0

for i, cert := range chain {
fmt.Fprintf(
os.Stderr,
"[certbootstrap] persist input chain[%d] subject=%s issuer=%s isCA=%v\n",
i,
cert.Subject.String(),
cert.Issuer.String(),
cert.IsCA,
)

var name string
switch i {
case 0:
name = "leaf.pem"
case 1:
name = "intermediate.pem"
case 2:
name = "root.pem"
default:
name = fmt.Sprintf("cert-%d.pem", i)
}

path := filepath.Join(certsDir, name)

block := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
data := pem.EncodeToMemory(block)

if err := os.WriteFile(path, data, 0o600); err != nil {
return written, err
}

written++
fmt.Fprintf(os.Stderr, "[certbootstrap] wrote %s\n", path)
}

return written, nil
}
66 changes: 66 additions & 0 deletions internal/certbootstrap/preflight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package certbootstrap

import (
"errors"
"fmt"
"os"
"runtime"
)

var ErrSkipBootstrap = errors.New("skip bootstrap")

func Preflight(args []string) error {
fmt.Fprintf(os.Stderr, "[certbootstrap] preflight entered goos=%s args=%q\n", runtime.GOOS, args)

if runtime.GOOS != "windows" {
fmt.Fprintln(os.Stderr, "[certbootstrap] skip: non-windows")
return ErrSkipBootstrap
}

targetURL, err := ResolveTargetURL(args)
if err != nil {
fmt.Fprintf(os.Stderr, "[certbootstrap] resolve url error: %v\n", err)
return err
}
if targetURL == "" {
fmt.Fprintln(os.Stderr, "[certbootstrap] skip: no target url")
return ErrSkipBootstrap
}

fmt.Fprintf(os.Stderr, "[certbootstrap] resolved target url=%s\n", targetURL)

chain, trusted, err := GetVerifiedChainIfWindowsTrusts(targetURL)
if err != nil {
fmt.Fprintf(os.Stderr, "[certbootstrap] verified probe error: %v\n", err)
return err
}
if !trusted {
fmt.Fprintln(os.Stderr, "[certbootstrap] target not trusted by windows/go")
return ErrSkipBootstrap
}

fmt.Fprintf(os.Stderr, "[certbootstrap] verified chain length=%d\n", len(chain))
for i, cert := range chain {
fmt.Fprintf(
os.Stderr,
"[certbootstrap] chain[%d] subject=%s issuer=%s isCA=%v\n",
i,
cert.Subject.String(),
cert.Issuer.String(),
cert.IsCA,
)
}


jfrogHome := ResolveJfrogHomeDir()
fmt.Fprintf(os.Stderr, "[certbootstrap] jfrog home=%s\n", jfrogHome)

written, err := PersistVerifiedChain(jfrogHome, chain)
if err != nil {
fmt.Fprintf(os.Stderr, "[certbootstrap] persist error: %v\n", err)
return err
}

fmt.Fprintf(os.Stderr, "[certbootstrap] persisted cert count=%d\n", written)
return ErrSkipBootstrap
}
74 changes: 74 additions & 0 deletions internal/certbootstrap/probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package certbootstrap

import (
"crypto/tls"
"crypto/x509"
"net"
"net/url"
"os"
"strings"
"time"
)

func ResolveTargetURL(args []string) (string, error) {
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "--url=") {
return normalizeURL(strings.TrimPrefix(a, "--url="))
}
if a == "--url" && i+1 < len(args) {
return normalizeURL(args[i+1])
}
}

if u := os.Getenv("JFROG_URL"); u != "" {
return normalizeURL(u)
}

return "", nil
}

func normalizeURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", err
}
return u.String(), nil
}

func GetVerifiedChainIfWindowsTrusts(rawURL string) ([]*x509.Certificate, bool, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, false, err
}
if u.Scheme != "https" {
return nil, false, nil
}

host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}

conn, err := tls.DialWithDialer(
&net.Dialer{Timeout: 5 * time.Second},
"tcp",
net.JoinHostPort(host, port),
&tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
},
)
if err != nil {
return nil, false, nil
}
defer conn.Close()

state := conn.ConnectionState()
if len(state.VerifiedChains) == 0 || len(state.VerifiedChains[0]) == 0 {
return nil, false, nil
}

return state.VerifiedChains[0], true, nil
}
7 changes: 7 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"runtime"
"sort"
"strings"
"errors"
"github.com/jfrog/jfrog-cli/internal/certbootstrap"

statsDocs "github.com/jfrog/jfrog-cli/docs/general/stats"
"github.com/jfrog/jfrog-cli/general/ai"
Expand Down Expand Up @@ -98,6 +100,11 @@ func execMain() error {
app.Version = cliutils.GetVersion()
args := os.Args
cliutils.SetCliExecutableName(args[0])
if bootstrapErr := certbootstrap.Preflight(args); bootstrapErr != nil {
if !errors.Is(bootstrapErr, certbootstrap.ErrSkipBootstrap) {
clientlog.Debug("auto CA bootstrap skipped:", bootstrapErr)
}
}
app.EnableBashCompletion = true
commands, err := getCommands()
if err != nil {
Expand Down
Loading