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: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@

`jx` is the modular command line CLI for [Jenkins X 3.x](https://jenkins-x.io/v3/about/)

## Supported Kubernetes Versions

Jenkins X 3.x currently supports the following Kubernetes versions:
* **1.32** (Latest)
* **1.31**
* **1.30**

For more information on Jenkins X architecture and requirements, see the [about page](https://jenkins-x.io/v3/about/).

## Commands

See the [jx command reference](https://jenkins-x.io/v3/develop/reference/jx/)
Expand Down
58 changes: 56 additions & 2 deletions pkg/cmd/upgrade/upgrade_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/rhysd/go-github-selfupdate/selfupdate"

"github.com/blang/semver"
goupdate "github.com/inconshreveable/go-update"
"github.com/jenkins-x/jx-helpers/v3/pkg/versionstream"

"github.com/jenkins-x/jx-helpers/v3/pkg/cmdrunner"
Expand All @@ -34,6 +35,7 @@ import (
"github.com/jenkins-x/jx-helpers/v3/pkg/gitclient/cli"
"github.com/jenkins-x/jx-logging/v3/pkg/log"

jxutil "github.com/jenkins-x/jx/pkg/util"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -239,14 +241,66 @@ func (*CLIOptions) InstallJx(upgrade bool, version string) error {
}
log.Logger().Infof("downloading version %s...", version)
clientURL := fmt.Sprintf("%s%s/"+binary+"-%s-%s.%s", BinaryDownloadBaseURL, version, runtime.GOOS, runtime.GOARCH, extension)
sigURL := clientURL + ".sig"

// Download the binary to a temporary file
tmpDir, err := os.MkdirTemp("", "jx-upgrade-")
if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)

artifactPath := filepath.Join(tmpDir, binary+"."+extension)
sigPath := artifactPath + ".sig"

log.Logger().Infof("Downloading %s ...", termcolor.ColorInfo(clientURL))
err = jxutil.DownloadFile(clientURL, artifactPath)
if err != nil {
return fmt.Errorf("failed to download binary from %s to %s: %w", clientURL, artifactPath, err)
}

log.Logger().Infof("Downloading signature %s ...", termcolor.ColorInfo(sigURL))
err = jxutil.DownloadFile(sigURL, sigPath)
if err != nil {
log.Logger().Warnf("failed to download signature from %s: %v", sigURL, err)
log.Logger().Warnf("proceeding without signature verification (not yet mandatory for all releases)")
} else {
// Load public key
publicKeyURL := "https://raw.githubusercontent.com/jenkins-x/jx/main/jx.pub"
publicKeyPEM, err := jxutil.GetFileAsString(publicKeyURL)
if err != nil {
return fmt.Errorf("failed to download public key from %s: %w", publicKeyURL, err)
}

err = jxutil.VerifySignature(artifactPath, sigPath, []byte(publicKeyPEM))
if err != nil {
return fmt.Errorf("signature verification failed for %s: %w", clientURL, err)
}
log.Logger().Infof("Signature verification successful for %s", termcolor.ColorInfo(clientURL))
}

exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get the jx executable which is running this command: %w", err)
}

err = selfupdate.UpdateTo(clientURL, exe)
f, err := os.Open(artifactPath)
if err != nil {
return fmt.Errorf("failed to open downloaded artifact: %w", err)
}
defer f.Close()

// Uncompress if needed
reader, err := selfupdate.UncompressCommand(f, clientURL, binary)
if err != nil {
return fmt.Errorf("failed to uncompress artifact: %w", err)
}

err = goupdate.Apply(reader, goupdate.Options{
TargetPath: exe,
})
if err != nil {
return fmt.Errorf("failed to upgrade jx cli to version %s: %w", version, err)
return fmt.Errorf("failed to upgrade jx cli from %s: %w", artifactPath, err)
}
log.Logger().Infof("Jenkins X client has been upgraded to version %s", version)
return nil
Expand Down
18 changes: 18 additions & 0 deletions pkg/cmd/upgrade/upgrade_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/jenkins-x/jx-helpers/v3/pkg/termcolor"
"github.com/jenkins-x/jx-logging/v3/pkg/log"
"github.com/jenkins-x/jx/pkg/plugins"
jxutil "github.com/jenkins-x/jx/pkg/util"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -86,6 +87,23 @@ func (o *PluginOptions) Run() error {
return fmt.Errorf("failed to ensure plugin is installed %s: %w", p.Name, err)
}

// Verify signature if available
sigURL := p.Spec.Binaries[0].URL + ".sig" // This is a heuristic, but often correct for JX plugins
sigPath := fileName + ".sig"
log.Logger().Debugf("Checking signature for plugin %s from %s", p.Name, sigURL)

err = jxutil.DownloadFile(sigURL, sigPath)
if err == nil {
defer os.Remove(sigPath)
// Load public key
publicKeyURL := "https://raw.githubusercontent.com/jenkins-x/jx/main/jx.pub"
_, err := jxutil.GetFileAsString(publicKeyURL)
if err == nil {
// ToDo: once jx-helpers supports passing a verified archive we can verify BEFORE EnsurePluginInstalled
log.Logger().Debugf("signature found for plugin %s, but verification of unpacked binaries is not yet implemented", p.Name)
}
}

if o.Boot {
if p.Name == "gitops" {
c := &cmdrunner.Command{
Expand Down
101 changes: 101 additions & 0 deletions pkg/util/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package util

import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
)

// DownloadFile downloads a file from a URL to a local destination.
func DownloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download %s: status %s", url, resp.Status)
}

f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()

_, err = io.Copy(f, resp.Body)
return err
}

// GetFileAsString downloads a file from a URL and returns its content as a string.
func GetFileAsString(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download %s: status %s", url, resp.Status)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}

// VerifySignature verifies the signature of a file against a PEM-encoded public key.
// The signature is expected to be a Base64-encoded ASN.1 (DER) ECDSA signature.
func VerifySignature(artifactPath, signaturePath string, publicKeyPEM []byte) error {
// Read the artifact content
artifactBytes, err := os.ReadFile(artifactPath)
if err != nil {
return fmt.Errorf("failed to read artifact file %s: %w", artifactPath, err)
}

// Read and decode the signature
signatureEncoded, err := os.ReadFile(signaturePath)
if err != nil {
return fmt.Errorf("failed to read signature file %s: %w", signaturePath, err)
}
signatureBytes, err := base64.StdEncoding.DecodeString(string(signatureEncoded))
if err != nil {
// Try raw if base64 decoding fails
signatureBytes = signatureEncoded
}

// Parse the public key
block, _ := pem.Decode(publicKeyPEM)
if block == nil {
return fmt.Errorf("failed to decode public key PEM")
}
pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
pubKey, ok := pubInterface.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not an ECDSA key")
}

// Hash the artifact
h := sha256.New()
h.Write(artifactBytes)
digest := h.Sum(nil)

// Verify the signature
if !ecdsa.VerifyASN1(pubKey, digest, signatureBytes) {
return fmt.Errorf("invalid signature for artifact %s", artifactPath)
}

return nil
}
70 changes: 70 additions & 0 deletions pkg/util/verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package util

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"os"
"path/filepath"
"testing"

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

func TestVerifySignature(t *testing.T) {
// 1. Generate a key pair
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)

pubBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
require.NoError(t, err)

pubPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
})

// 2. Create a dummy artifact
tmpDir := t.TempDir()
artifactPath := filepath.Join(tmpDir, "artifact.txt")
content := []byte("hello world")
err = os.WriteFile(artifactPath, content, 0644)
require.NoError(t, err)

// 3. Sign the artifact
h := sha256.New()
h.Write(content)
digest := h.Sum(nil)
signature, err := privateKey.Sign(rand.Reader, digest, nil)
require.NoError(t, err)

signaturePath := filepath.Join(tmpDir, "artifact.txt.sig")
err = os.WriteFile(signaturePath, []byte(base64.StdEncoding.EncodeToString(signature)), 0644)
require.NoError(t, err)

// 4. Test success case
err = VerifySignature(artifactPath, signaturePath, pubPEM)
assert.NoError(t, err)

// 5. Test failure case (corrupted artifact)
err = os.WriteFile(artifactPath, []byte("corrupted"), 0644)
require.NoError(t, err)
err = VerifySignature(artifactPath, signaturePath, pubPEM)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid signature")

// 6. Test failure case (wrong key)
otherKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
otherPubBytes, _ := x509.MarshalPKIXPublicKey(&otherKey.PublicKey)
otherPubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: otherPubBytes})

err = os.WriteFile(artifactPath, content, 0644) // restore content
require.NoError(t, err)
err = VerifySignature(artifactPath, signaturePath, otherPubPEM)
assert.Error(t, err)
}