Skip to content
Draft
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
60 changes: 60 additions & 0 deletions cmd/secrets/common/encryption.go
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions cmd/secrets/common/encryption_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
122 changes: 122 additions & 0 deletions cmd/secrets/decrypt_output/decrypt_output.go
Original file line number Diff line number Diff line change
@@ -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 "<base64>" | 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)
}
}
4 changes: 4 additions & 0 deletions cmd/secrets/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
82 changes: 82 additions & 0 deletions cmd/secrets/store_encryption_key/store_encryption_key.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading