diff --git a/cmd/readPipelineEnv.go b/cmd/readPipelineEnv.go index a4c2ef801..91663fedf 100644 --- a/cmd/readPipelineEnv.go +++ b/cmd/readPipelineEnv.go @@ -1,18 +1,13 @@ package cmd import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "encoding/base64" "encoding/json" "fmt" - "io" "os" "path" "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/encryption" "github.com/SAP/jenkins-library/pkg/log" "github.com/SAP/jenkins-library/pkg/piperenv" "github.com/spf13/cobra" @@ -69,7 +64,7 @@ func runReadPipelineEnv(stepConfigPassword string, encryptedCPE bool) error { } cpeJsonBytes, _ := json.Marshal(cpe) - encryptedCPEBytes, err := encrypt([]byte(stepConfigPassword), cpeJsonBytes) + encryptedCPEBytes, err := encryption.Encrypt([]byte(stepConfigPassword), cpeJsonBytes) if err != nil { log.Entry().Fatal(err) } @@ -87,28 +82,3 @@ func runReadPipelineEnv(stepConfigPassword string, encryptedCPE bool) error { return nil } - -func encrypt(secret, inBytes []byte) ([]byte, error) { - // use SHA256 as key - key := sha256.Sum256(secret) - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, fmt.Errorf("failed to create new cipher: %v", err) - } - - // Make the cipher text a byte array of size BlockSize + the length of the message - cipherText := make([]byte, aes.BlockSize+len(inBytes)) - - // iv is the ciphertext up to the blocksize (16) - iv := cipherText[:aes.BlockSize] - if _, err = io.ReadFull(rand.Reader, iv); err != nil { - return nil, fmt.Errorf("failed to init iv: %v", err) - } - - // Encrypt the data: - stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(cipherText[aes.BlockSize:], inBytes) - - // Return string encoded in base64 - return []byte(base64.StdEncoding.EncodeToString(cipherText)), err -} diff --git a/cmd/readPipelineEnv_test.go b/cmd/readPipelineEnv_test.go index 6efcb2ff6..f11edd783 100644 --- a/cmd/readPipelineEnv_test.go +++ b/cmd/readPipelineEnv_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/SAP/jenkins-library/pkg/encryption" "github.com/stretchr/testify/assert" ) @@ -11,11 +12,11 @@ func TestCpeEncryption(t *testing.T) { secret := []byte("testKey!") payload := []byte(strings.Repeat("testString", 100)) - encrypted, err := encrypt(secret, payload) + encrypted, err := encryption.Encrypt(secret, payload) assert.NoError(t, err) assert.NotNil(t, encrypted) - decrypted, err := decrypt(secret, encrypted) + decrypted, err := encryption.Decrypt(secret, encrypted) assert.NoError(t, err) assert.Equal(t, decrypted, payload) } diff --git a/cmd/writePipelineEnv.go b/cmd/writePipelineEnv.go index cbc6842c0..edbeac057 100644 --- a/cmd/writePipelineEnv.go +++ b/cmd/writePipelineEnv.go @@ -2,27 +2,28 @@ package cmd import ( "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/sha256" - b64 "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" + "strings" "github.com/SAP/jenkins-library/pkg/config" - + "github.com/SAP/jenkins-library/pkg/encryption" "github.com/SAP/jenkins-library/pkg/log" "github.com/SAP/jenkins-library/pkg/piperenv" "github.com/spf13/cobra" ) // WritePipelineEnv Serializes the commonPipelineEnvironment JSON to disk +// Can be used in two modes: +// 1. JSON serialization: processes JSON input from stdin or PIPER_pipelineEnv environment variable +// 2. Direct value: writes a single key-value pair using the --value flag (format: key=value) func WritePipelineEnv() *cobra.Command { var stepConfig artifactPrepareVersionOptions var encryptedCPE bool + var directValue string metadata := artifactPrepareVersionMetadata() writePipelineEnv := &cobra.Command{ @@ -43,6 +44,13 @@ func WritePipelineEnv() *cobra.Command { }, Run: func(cmd *cobra.Command, args []string) { + if directValue != "" { + err := writeDirectValue(directValue) + if err != nil { + log.Entry().Fatalf("error when writing direct value: %v", err) + } + return + } err := runWritePipelineEnv(stepConfig.Password, encryptedCPE) if err != nil { log.Entry().Fatalf("error when writing common Pipeline environment: %v", err) @@ -51,85 +59,94 @@ func WritePipelineEnv() *cobra.Command { } writePipelineEnv.Flags().BoolVar(&encryptedCPE, "encryptedCPE", false, "Bool to use encryption in CPE") + writePipelineEnv.Flags().StringVar(&directValue, "value", "", "Key-value pair to write directly (format: key=value)") return writePipelineEnv } func runWritePipelineEnv(stepConfigPassword string, encryptedCPE bool) error { - var err error - pipelineEnv, ok := os.LookupEnv("PIPER_pipelineEnv") - inBytes := []byte(pipelineEnv) - if !ok { - var err error - inBytes, err = io.ReadAll(os.Stdin) - if err != nil { - return err - } + inBytes, err := readInput() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) } if len(inBytes) == 0 { return nil } - // try to decrypt if encryptedCPE { - log.Entry().Debug("trying to decrypt CPE") - if stepConfigPassword == "" { - return fmt.Errorf("empty stepConfigPassword") - } - - inBytes, err = decrypt([]byte(stepConfigPassword), inBytes) - if err != nil { - log.Entry().Fatal(err) + if inBytes, err = handleEncryption(stepConfigPassword, inBytes); err != nil { + return err } } + commonPipelineEnv, err := parseInput(inBytes) + if err != nil { + return fmt.Errorf("failed to parse input: %w", err) + } + + if _, err := writeOutput(commonPipelineEnv); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + + return nil +} + +func readInput() ([]byte, error) { + if pipelineEnv, ok := os.LookupEnv("PIPER_pipelineEnv"); ok { + return []byte(pipelineEnv), nil + } + return io.ReadAll(os.Stdin) +} + +func handleEncryption(password string, data []byte) ([]byte, error) { + if password == "" { + return nil, fmt.Errorf("encryption enabled but password is empty") + } + log.Entry().Debug("decrypting CPE data") + return encryption.Decrypt([]byte(password), data) +} + +func parseInput(data []byte) (piperenv.CPEMap, error) { commonPipelineEnv := piperenv.CPEMap{} - decoder := json.NewDecoder(bytes.NewReader(inBytes)) + decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() - err = decoder.Decode(&commonPipelineEnv) - if err != nil { - return err + if err := decoder.Decode(&commonPipelineEnv); err != nil { + return nil, err } + return commonPipelineEnv, nil +} +func writeOutput(commonPipelineEnv piperenv.CPEMap) (int, error) { rootPath := filepath.Join(GeneralConfig.EnvRootPath, "commonPipelineEnvironment") - err = commonPipelineEnv.WriteToDisk(rootPath) - if err != nil { - return err + if err := commonPipelineEnv.WriteToDisk(rootPath); err != nil { + return 0, err } writtenBytes, err := json.MarshalIndent(commonPipelineEnv, "", "\t") if err != nil { - return err + return 0, err } - _, err = os.Stdout.Write(writtenBytes) - if err != nil { - return err - } - return nil + return os.Stdout.Write(writtenBytes) } -func decrypt(secret, base64CipherText []byte) ([]byte, error) { - // decode from base64 - cipherText, err := b64.StdEncoding.DecodeString(string(base64CipherText)) - if err != nil { - return nil, fmt.Errorf("failed to decode from base64: %v", err) +// writeDirectValue writes a single value to a file in the commonPipelineEnvironment directory +// The key-value pair should be in the format "key=value" +// The key will be used as the file name and the value as its content +func writeDirectValue(keyValue string) error { + parts := strings.SplitN(keyValue, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid key-value format. Expected 'key=value', got '%s'", keyValue) } - // use SHA256 as key - key := sha256.Sum256(secret) - block, err := aes.NewCipher(key[:]) - if err != nil { - return nil, fmt.Errorf("failed to create new cipher: %v", err) + key := parts[0] + value := parts[1] + + rootPath := filepath.Join(GeneralConfig.EnvRootPath, "commonPipelineEnvironment") + filePath := filepath.Join(rootPath, key) + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed to create directory: %v", err) } - if len(cipherText) < aes.BlockSize { - return nil, fmt.Errorf("invalid ciphertext block size") - } - - iv := cipherText[:aes.BlockSize] - cipherText = cipherText[aes.BlockSize:] - - stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(cipherText, cipherText) - - return cipherText, nil + return os.WriteFile(filePath, []byte(value), 0644) } diff --git a/pkg/encryption/encryption.go b/pkg/encryption/encryption.go new file mode 100644 index 000000000..2f43d059a --- /dev/null +++ b/pkg/encryption/encryption.go @@ -0,0 +1,61 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" +) + +// Decrypt decrypts base64-encoded data using AES-CFB +func Decrypt(secret, base64CipherText []byte) ([]byte, error) { + cipherText, err := base64.StdEncoding.DecodeString(string(base64CipherText)) + if err != nil { + return nil, fmt.Errorf("failed to decode from base64: %w", err) + } + + key := sha256.Sum256(secret) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + if len(cipherText) < aes.BlockSize { + return nil, fmt.Errorf("invalid ciphertext: block size too small") + } + + iv := cipherText[:aes.BlockSize] + cipherText = cipherText[aes.BlockSize:] + + stream := cipher.NewCFBDecrypter(block, iv) + stream.XORKeyStream(cipherText, cipherText) + + return cipherText, nil +} + +// Encrypt encrypts data using AES-CFB and encodes it in base64 +func Encrypt(secret, inBytes []byte) ([]byte, error) { + if len(secret) == 0 { + return nil, fmt.Errorf("failed to create cipher: empty secret") + } + + key := sha256.Sum256(secret) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + cipherText := make([]byte, aes.BlockSize+len(inBytes)) + iv := cipherText[:aes.BlockSize] + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return nil, fmt.Errorf("failed to init iv: %w", err) + } + + stream := cipher.NewCFBEncrypter(block, iv) + stream.XORKeyStream(cipherText[aes.BlockSize:], inBytes) + + return []byte(base64.StdEncoding.EncodeToString(cipherText)), nil +} diff --git a/pkg/encryption/encryption_test.go b/pkg/encryption/encryption_test.go new file mode 100644 index 000000000..e98227d17 --- /dev/null +++ b/pkg/encryption/encryption_test.go @@ -0,0 +1,108 @@ +package encryption + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDecrypt(t *testing.T) { + t.Run("successful decryption", func(t *testing.T) { + secret := []byte("test-secret-key") + plaintext := []byte("hello world") + + // Encrypt first using our package function + encrypted, err := Encrypt(secret, plaintext) + assert.NoError(t, err) + + // Test decryption + decrypted, err := Decrypt(secret, encrypted) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + }) + + t.Run("invalid base64 input", func(t *testing.T) { + secret := []byte("test-secret-key") + invalidBase64 := []byte("this is not base64!") + + decrypted, err := Decrypt(secret, invalidBase64) + assert.Error(t, err) + assert.Nil(t, decrypted) + assert.Contains(t, err.Error(), "failed to decode from base64") + }) + + t.Run("input too small", func(t *testing.T) { + secret := []byte("test-secret-key") + tooSmall := base64.StdEncoding.EncodeToString([]byte("small")) + + decrypted, err := Decrypt(secret, []byte(tooSmall)) + assert.Error(t, err) + assert.Nil(t, decrypted) + assert.Contains(t, err.Error(), "invalid ciphertext: block size too small") + }) + + t.Run("empty input", func(t *testing.T) { + secret := []byte("test-secret-key") + empty := []byte("") + + decrypted, err := Decrypt(secret, empty) + assert.Error(t, err) + assert.Nil(t, decrypted) + }) +} + +func TestEncrypt(t *testing.T) { + t.Run("successful encryption", func(t *testing.T) { + secret := []byte("test-secret-key") + plaintext := []byte("hello world") + + encrypted, err := Encrypt(secret, plaintext) + assert.NoError(t, err) + assert.NotNil(t, encrypted) + + // Verify we can decrypt it back + decrypted, err := Decrypt(secret, encrypted) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + }) + + t.Run("empty input", func(t *testing.T) { + secret := []byte("test-secret-key") + empty := []byte("") + + encrypted, err := Encrypt(secret, empty) + assert.NoError(t, err) + assert.NotNil(t, encrypted) + + // Verify we can decrypt it back + decrypted, err := Decrypt(secret, encrypted) + assert.NoError(t, err) + assert.Equal(t, empty, decrypted) + }) + + t.Run("empty secret", func(t *testing.T) { + secret := []byte("") + plaintext := []byte("hello world") + + encrypted, err := Encrypt(secret, plaintext) + assert.Error(t, err) + assert.Nil(t, encrypted) + assert.Contains(t, err.Error(), "failed to create cipher: empty secret") + }) + + t.Run("large input", func(t *testing.T) { + secret := []byte("test-secret-key") + largeInput := []byte(strings.Repeat("large input test ", 1000)) + + encrypted, err := Encrypt(secret, largeInput) + assert.NoError(t, err) + assert.NotNil(t, encrypted) + + // Verify we can decrypt it back + decrypted, err := Decrypt(secret, encrypted) + assert.NoError(t, err) + assert.Equal(t, largeInput, decrypted) + }) +}