diff --git a/cmd/secrets/common/encryption.go b/cmd/secrets/common/encryption.go new file mode 100644 index 00000000..edfcb5da --- /dev/null +++ b/cmd/secrets/common/encryption.go @@ -0,0 +1,60 @@ +package common + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +const ( + // EncryptionKeySecretName is the VaultDON secret name used for AES-GCM encryption + // of confidential HTTP responses. + EncryptionKeySecretName = "san_marino_aes_gcm_encryption_key" + + hkdfInfo = "confidential-http-encryption-key-v1" + aesKeyLen = 32 + gcmNonceLen = 12 + gcmTagLen = 16 +) + +// DeriveEncryptionKey applies HKDF-SHA256 to a user passphrase and returns a +// 32-byte AES-256 key. +func DeriveEncryptionKey(passphrase string) ([]byte, error) { + r := hkdf.New(sha256.New, []byte(passphrase), nil, []byte(hkdfInfo)) + key := make([]byte, aesKeyLen) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("hkdf expand: %w", err) + } + return key, nil +} + +// AESGCMDecrypt decrypts AES-GCM ciphertext. +// Wire format: [12-byte nonce][ciphertext+16-byte GCM tag] +func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) { + if len(ciphertext) < gcmNonceLen+gcmTagLen { + return nil, fmt.Errorf("ciphertext too short: need at least %d bytes, got %d", gcmNonceLen+gcmTagLen, len(ciphertext)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes.NewCipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("cipher.NewGCM: %w", err) + } + + nonce := ciphertext[:gcmNonceLen] + sealed := ciphertext[gcmNonceLen:] + plaintext, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + return nil, fmt.Errorf("gcm decrypt: %w", err) + } + + return plaintext, nil +} diff --git a/cmd/secrets/common/encryption_test.go b/cmd/secrets/common/encryption_test.go new file mode 100644 index 00000000..a07735de --- /dev/null +++ b/cmd/secrets/common/encryption_test.go @@ -0,0 +1,64 @@ +package common + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "testing" +) + +const ( + testPassphrase = "test-passphrase-for-ci" + testExpectedHex = "521af99325c07c9bd0d224c5bf3ca25666c68b5fbb7fa7884019b4f60a8e6eb5" +) + +func TestDeriveEncryptionKey_CrossLanguageVector(t *testing.T) { + key, err := DeriveEncryptionKey(testPassphrase) + if err != nil { + t.Fatal(err) + } + got := hex.EncodeToString(key) + if got != testExpectedHex { + t.Fatalf("HKDF vector mismatch:\n got: %s\n want: %s", got, testExpectedHex) + } +} + +func TestAESGCMDecrypt_RoundTrip(t *testing.T) { + key, err := DeriveEncryptionKey("round-trip-test") + if err != nil { + t.Fatal(err) + } + + plaintext := []byte("hello confidential http") + + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + sealed := gcm.Seal(nonce, nonce, plaintext, nil) + + got, err := AESGCMDecrypt(sealed, key) + if err != nil { + t.Fatal(err) + } + if string(got) != string(plaintext) { + t.Fatalf("decrypted = %q, want %q", got, plaintext) + } +} + +func TestAESGCMDecrypt_TooShort(t *testing.T) { + key, _ := DeriveEncryptionKey("any") + _, err := AESGCMDecrypt(make([]byte, 10), key) + if err == nil { + t.Fatal("expected error for short ciphertext") + } +} diff --git a/cmd/secrets/decrypt_output/decrypt_output.go b/cmd/secrets/decrypt_output/decrypt_output.go new file mode 100644 index 00000000..00cf9972 --- /dev/null +++ b/cmd/secrets/decrypt_output/decrypt_output.go @@ -0,0 +1,122 @@ +package decrypt_output + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common" + "github.com/smartcontractkit/cre-cli/internal/runtime" +) + +// New creates and returns the 'secrets decrypt-output' cobra command. +func New(_ *runtime.Context) *cobra.Command { + var ( + passphrase string + input string + encoding string + ) + + cmd := &cobra.Command{ + Use: "decrypt-output", + Short: "Decrypts an AES-GCM encrypted response body using a passphrase.", + Long: `Derives the AES-256 key from the given passphrase (same HKDF-SHA256 derivation +as store-encryption-key) and decrypts the provided ciphertext. + +This is a purely local operation; no VaultDON interaction required.`, + Example: ` # Decrypt base64-encoded ciphertext from a file + cre secrets decrypt-output --passphrase "my-secret" --input encrypted.b64 + + # Decrypt from stdin (pipe) + echo "" | cre secrets decrypt-output --passphrase "my-secret" --input - + + # Decrypt hex-encoded ciphertext + cre secrets decrypt-output --passphrase "my-secret" --input encrypted.hex --encoding hex + + # Decrypt raw binary ciphertext + cre secrets decrypt-output --passphrase "my-secret" --input encrypted.bin --encoding raw`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if passphrase == "" { + return fmt.Errorf("--passphrase is required and must not be empty") + } + if input == "" { + return fmt.Errorf("--input is required") + } + + raw, err := readInput(input) + if err != nil { + return err + } + + ciphertext, err := decodeCiphertext(raw, encoding) + if err != nil { + return err + } + + key, err := common.DeriveEncryptionKey(passphrase) + if err != nil { + return fmt.Errorf("failed to derive encryption key: %w", err) + } + + plaintext, err := common.AESGCMDecrypt(ciphertext, key) + if err != nil { + return fmt.Errorf("decryption failed: %w", err) + } + + _, err = os.Stdout.Write(plaintext) + return err + }, + } + + cmd.Flags().StringVar(&passphrase, "passphrase", "", "Passphrase used to derive the AES-256 decryption key (required)") + cmd.Flags().StringVarP(&input, "input", "i", "", "File path containing ciphertext, or '-' for stdin (required)") + cmd.Flags().StringVar(&encoding, "encoding", "base64", "Encoding of the input ciphertext: base64, hex, or raw") + _ = cmd.MarkFlagRequired("passphrase") + _ = cmd.MarkFlagRequired("input") + + return cmd +} + +func readInput(path string) ([]byte, error) { + if path == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read stdin: %w", err) + } + return data, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file %q: %w", path, err) + } + return data, nil +} + +func decodeCiphertext(raw []byte, encoding string) ([]byte, error) { + switch encoding { + case "base64": + decoded, err := base64.StdEncoding.DecodeString(string(raw)) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(string(raw)) + if err != nil { + return nil, fmt.Errorf("base64 decode failed: %w", err) + } + } + return decoded, nil + case "hex": + decoded, err := hex.DecodeString(string(raw)) + if err != nil { + return nil, fmt.Errorf("hex decode failed: %w", err) + } + return decoded, nil + case "raw": + return raw, nil + default: + return nil, fmt.Errorf("unsupported encoding %q: use base64, hex, or raw", encoding) + } +} diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index db11100d..2d78f600 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -6,9 +6,11 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/secrets/create" + "github.com/smartcontractkit/cre-cli/cmd/secrets/decrypt_output" "github.com/smartcontractkit/cre-cli/cmd/secrets/delete" "github.com/smartcontractkit/cre-cli/cmd/secrets/execute" "github.com/smartcontractkit/cre-cli/cmd/secrets/list" + "github.com/smartcontractkit/cre-cli/cmd/secrets/store_encryption_key" "github.com/smartcontractkit/cre-cli/cmd/secrets/update" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -37,6 +39,8 @@ func New(runtimeContext *runtime.Context) *cobra.Command { secretsCmd.AddCommand(delete.New(runtimeContext)) secretsCmd.AddCommand(list.New(runtimeContext)) secretsCmd.AddCommand(execute.New(runtimeContext)) + secretsCmd.AddCommand(store_encryption_key.New(runtimeContext)) + secretsCmd.AddCommand(decrypt_output.New(runtimeContext)) return secretsCmd } diff --git a/cmd/secrets/store_encryption_key/store_encryption_key.go b/cmd/secrets/store_encryption_key/store_encryption_key.go new file mode 100644 index 00000000..8b35ac5d --- /dev/null +++ b/cmd/secrets/store_encryption_key/store_encryption_key.go @@ -0,0 +1,82 @@ +package store_encryption_key + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common" + "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// New creates and returns the 'secrets store-encryption-key' cobra command. +func New(ctx *runtime.Context) *cobra.Command { + var passphrase string + + cmd := &cobra.Command{ + Use: "store-encryption-key", + Short: "Derives an AES-256 encryption key from a passphrase and stores it in VaultDON.", + Long: `Derives a 32-byte AES-256 key from the given passphrase using HKDF-SHA256, +then stores it in VaultDON under the name "san_marino_aes_gcm_encryption_key". + +This key is used by the confidential-http capability to encrypt response bodies +when EncryptOutput is set to true.`, + Example: ` cre secrets store-encryption-key --passphrase "my-secret-passphrase" + cre secrets store-encryption-key --passphrase "my-secret-passphrase" --unsigned`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if passphrase == "" { + return fmt.Errorf("--passphrase is required and must not be empty") + } + + key, err := common.DeriveEncryptionKey(passphrase) + if err != nil { + return fmt.Errorf("failed to derive encryption key: %w", err) + } + + // Build a single-entry secrets payload with the derived key as a hex-encoded value. + inputs := common.UpsertSecretsInputs{ + { + ID: common.EncryptionKeySecretName, + Value: hex.EncodeToString(key), + Namespace: "main", + }, + } + + // The handler needs a secrets file path for bundle naming. Use a + // synthetic path so bundles land in the current directory. + h, err := common.NewHandler(ctx, "encryption-key-secrets.yaml") + if err != nil { + return err + } + + duration, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return err + } + + maxDuration := constants.MaxVaultAllowlistDuration + maxHours := int(maxDuration / time.Hour) + maxDays := int(maxDuration / (24 * time.Hour)) + if duration <= 0 || duration > maxDuration { + return fmt.Errorf("invalid --timeout: must be greater than 0 and less than %dh (%dd)", maxHours, maxDays) + } + + return h.Execute(inputs, vaulttypes.MethodSecretsCreate, duration, + ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType) + }, + } + + cmd.Flags().StringVar(&passphrase, "passphrase", "", "Passphrase used to derive the AES-256 encryption key (required)") + _ = cmd.MarkFlagRequired("passphrase") + + settings.AddTxnTypeFlags(cmd) + settings.AddSkipConfirmation(cmd) + return cmd +}