Skip to content
Merged
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
5 changes: 0 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,11 +503,6 @@ func isLoadDeploymentRPC(cmd *cobra.Command) bool {
"cre workflow delete": {},
"cre account link-key": {},
"cre account unlink-key": {},
"cre secrets create": {},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Needed to move to the execution layer as browser flow should not require RPC

"cre secrets delete": {},
"cre secrets execute": {},
"cre secrets list": {},
"cre secrets update": {},
}
_, exists := includedCommands[cmd.CommandPath()]
return exists
Expand Down
153 changes: 153 additions & 0 deletions cmd/secrets/common/browser_flow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package common

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"

"github.com/google/uuid"
"github.com/machinebox/graphql"

"github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
"github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"

"github.com/smartcontractkit/cre-cli/internal/client/graphqlclient"
"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/credentials"
"github.com/smartcontractkit/cre-cli/internal/ui"
)

const createVaultAuthURLMutation = `mutation CreateVaultAuthorizationUrl($request: VaultAuthorizationUrlRequest!) {
createVaultAuthorizationUrl(request: $request) {
url
}
}`

// vaultPermissionForMethod returns the API permission name for the given vault operation.
func vaultPermissionForMethod(method string) (string, error) {
switch method {
case vaulttypes.MethodSecretsCreate:
return "VAULT_PERMISSION_CREATE_SECRETS", nil
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

list and delete support to be added in follow up PR

case vaulttypes.MethodSecretsUpdate:
return "VAULT_PERMISSION_UPDATE_SECRETS", nil
default:
return "", fmt.Errorf("unsupported method: %s", method)
}
}

func digestHexString(digest [32]byte) string {
return "0x" + hex.EncodeToString(digest[:])
}

// executeBrowserUpsert handles secrets create/update when the user signs in with their organization account.
// It encrypts the payload, binds a digest, and completes the platform authorization request for this step.
func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecretsInputs, method string) error {
if h.Credentials.AuthType == credentials.AuthTypeApiKey {
return fmt.Errorf("this sign-in flow requires an interactive login; API keys are not supported")
}
orgID, err := h.Credentials.GetOrgID()
if err != nil {
return fmt.Errorf("organization information is missing from your session; sign in again or use owner-key-signing: %w", err)
}

ui.Dim("Using your account to authorize vault access for your organization...")

encSecrets, err := h.EncryptSecretsForBrowserOrg(inputs, orgID)
if err != nil {
return fmt.Errorf("failed to encrypt secrets: %w", err)
}
requestID := uuid.New().String()

var digest [32]byte

switch method {
case vaulttypes.MethodSecretsCreate:
req := jsonrpc2.Request[vault.CreateSecretsRequest]{
Version: jsonrpc2.JsonRpcVersion,
ID: requestID,
Method: method,
Params: &vault.CreateSecretsRequest{
RequestId: requestID,
EncryptedSecrets: encSecrets,
},
}
digest, err = CalculateDigest(req)
if err != nil {
return fmt.Errorf("failed to calculate create digest: %w", err)
}

case vaulttypes.MethodSecretsUpdate:
req := jsonrpc2.Request[vault.UpdateSecretsRequest]{
Version: jsonrpc2.JsonRpcVersion,
ID: requestID,
Method: method,
Params: &vault.UpdateSecretsRequest{
RequestId: requestID,
EncryptedSecrets: encSecrets,
},
}
digest, err = CalculateDigest(req)
if err != nil {
return fmt.Errorf("failed to calculate update digest: %w", err)
}

default:
return fmt.Errorf("unsupported method %q (expected %q or %q)", method, vaulttypes.MethodSecretsCreate, vaulttypes.MethodSecretsUpdate)
}

perm, err := vaultPermissionForMethod(method)
if err != nil {
return err
}

_, challenge, err := generatePKCES256()
if err != nil {
return err
}

gqlClient := graphqlclient.New(h.Credentials, h.EnvironmentSet, h.Log)
gqlReq := graphql.NewRequest(createVaultAuthURLMutation)
reqVars := map[string]any{
"codeChallenge": challenge,
"redirectUri": constants.AuthRedirectURI,
"requestDigest": digestHexString(digest),
"permission": perm,
}
// Optional: bind authorization to workflow owner when configured (omit if unset).
if w := strings.TrimSpace(h.OwnerAddress); w != "" {
reqVars["workflowOwnerAddress"] = w
}
gqlReq.Var("request", reqVars)

var gqlResp struct {
CreateVaultAuthorizationURL struct {
URL string `json:"url"`
} `json:"createVaultAuthorizationUrl"`
}
if err := gqlClient.Execute(ctx, gqlReq, &gqlResp); err != nil {
return fmt.Errorf("could not complete the authorization request")
}
if gqlResp.CreateVaultAuthorizationURL.URL == "" {
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.

So, this is just to complete the auth flow and receive JWT? We are not sending jsonrpc2.Request payload to the Gateway yet? That kind of confuses me in this function, because it feels like we should focus on the auth flow first, and then build jsonrpc2.Request for the requested secrets operation if the auth flow was successful.

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.

Nvm, I see it now, you need this because we need to send the request digest and it has to be attached to JWT. But are we missing the owner address in the GQL request? This is important if someone already has a secret owned by this address and now wants to switch over to org ownership.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that is correct.
I have not yet implemented this. I need to think further on it. For now I am treating them as two separate flows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

nvm, it's a small implementation so I have added it.

return fmt.Errorf("could not complete the authorization request")
}

ui.Success("Authorization completed successfully.")
return nil
}

// generatePKCES256 builds the PKCE verifier and challenge used for secure authorization.
func generatePKCES256() (verifier string, challenge string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("pkce random: %w", err)
}
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}
44 changes: 44 additions & 0 deletions cmd/secrets/common/browser_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package common

import (
"crypto/sha256"
"encoding/base64"
"testing"

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

"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"
)

func TestVaultPermissionForMethod(t *testing.T) {
p, err := vaultPermissionForMethod(vaulttypes.MethodSecretsCreate)
require.NoError(t, err)
assert.Equal(t, "VAULT_PERMISSION_CREATE_SECRETS", p)

p, err = vaultPermissionForMethod(vaulttypes.MethodSecretsUpdate)
require.NoError(t, err)
assert.Equal(t, "VAULT_PERMISSION_UPDATE_SECRETS", p)

_, err = vaultPermissionForMethod(vaulttypes.MethodSecretsDelete)
require.Error(t, err)
}

func TestDigestHexString(t *testing.T) {
var d [32]byte
copy(d[:], []byte{1, 2, 3})
assert.Equal(t, "0x0102030000000000000000000000000000000000000000000000000000000000", digestHexString(d))
}

// TestGeneratePKCES256 checks PKCE S256 (RFC 7636) used by the browser secrets authorization step.
func TestGeneratePKCES256(t *testing.T) {
verifier, challenge, err := generatePKCES256()
require.NoError(t, err)
require.NotEmpty(t, verifier)
require.NotEmpty(t, challenge)

sum := sha256.Sum256([]byte(verifier))
decoded, err := base64.RawURLEncoding.DecodeString(challenge)
require.NoError(t, err)
assert.Equal(t, sum[:], decoded)
}
95 changes: 76 additions & 19 deletions cmd/secrets/common/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package common
import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -103,6 +104,11 @@ func NewHandler(ctx *runtime.Context, secretsFilePath string) (*Handler, error)
return h, nil
}

// EnsureDeploymentRPCForOwnerKeySecrets checks project settings for an RPC URL on the workflow registry chain (owner-key / allowlist flows only).
func (h *Handler) EnsureDeploymentRPCForOwnerKeySecrets() error {
return settings.ValidateDeploymentRPC(&h.Settings.Workflow, h.EnvironmentSet.WorkflowRegistryChainName)
}

// ResolveInputs loads secrets from a YAML file.
// Errors if the path is not .yaml/.yml — MSIG step 2 is handled by `cre secrets execute`.
func (h *Handler) ResolveInputs() (UpsertSecretsInputs, error) {
Expand Down Expand Up @@ -220,8 +226,8 @@ func (h *Handler) LogMSIGNextSteps(txData string, digest [32]byte, bundlePath st
return nil
}

// EncryptSecrets takes the raw secrets and encrypts them, returning pointers.
func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs) ([]*vault.EncryptedSecret, error) {
// fetchVaultMasterPublicKeyHex loads the vault master public key from the gateway (publicKey/get).
func (h *Handler) fetchVaultMasterPublicKeyHex() (string, error) {
requestID := uuid.New().String()
getPublicKeyRequest := jsonrpc2.Request[vault.GetPublicKeyRequest]{
Version: jsonrpc2.JsonRpcVersion,
Expand All @@ -232,38 +238,47 @@ func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs) ([]*vault.Encry

reqBody, err := json.Marshal(getPublicKeyRequest)
if err != nil {
return nil, fmt.Errorf("failed to marshal public key request: %w", err)
return "", fmt.Errorf("failed to marshal public key request: %w", err)
}

respBody, status, err := h.Gw.Post(reqBody)
if err != nil {
return nil, fmt.Errorf("gateway POST failed: %w", err)
return "", fmt.Errorf("gateway POST failed: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("gateway returned non-200: %d body=%s", status, string(respBody))
return "", fmt.Errorf("gateway returned non-200: %d body=%s", status, string(respBody))
}

var rpcResp jsonrpc2.Response[vault.GetPublicKeyResponse]
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal public key response: %w", err)
return "", fmt.Errorf("failed to unmarshal public key response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("vault public key fetch error: %s", rpcResp.Error.Error())
return "", fmt.Errorf("vault public key fetch error: %s", rpcResp.Error.Error())
}
if rpcResp.Version != jsonrpc2.JsonRpcVersion {
return nil, fmt.Errorf("jsonrpc version mismatch: got %q", rpcResp.Version)
return "", fmt.Errorf("jsonrpc version mismatch: got %q", rpcResp.Version)
}
if rpcResp.ID != requestID {
return nil, fmt.Errorf("jsonrpc id mismatch: got %q want %q", rpcResp.ID, requestID)
return "", fmt.Errorf("jsonrpc id mismatch: got %q want %q", rpcResp.ID, requestID)
}
if rpcResp.Method != vaulttypes.MethodPublicKeyGet {
return nil, fmt.Errorf("jsonrpc method mismatch: got %q", rpcResp.Method)
return "", fmt.Errorf("jsonrpc method mismatch: got %q", rpcResp.Method)
}
if rpcResp.Result == nil || rpcResp.Result.PublicKey == "" {
return nil, fmt.Errorf("empty result in public key response")
return "", fmt.Errorf("empty result in public key response")
}

pubKeyHex := rpcResp.Result.PublicKey
return rpcResp.Result.PublicKey, nil
}

// EncryptSecrets takes the raw secrets and encrypts them, returning pointers.
// Owner-key flow: TDH2 label is the workflow owner address left-padded to 32 bytes; SecretIdentifier.Owner is the same hex address string.
func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs) ([]*vault.EncryptedSecret, error) {
pubKeyHex, err := h.fetchVaultMasterPublicKeyHex()
if err != nil {
return nil, err
}

encryptedSecrets := make([]*vault.EncryptedSecret, 0, len(rawSecrets))
for _, item := range rawSecrets {
Expand All @@ -284,7 +299,38 @@ func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs) ([]*vault.Encry
return encryptedSecrets, nil
}

func EncryptSecret(secret, masterPublicKeyHex string, ownerAddress string) (string, error) {
// EncryptSecretsForBrowserOrg encrypts secrets scoped to the signed-in organization (interactive sign-in flow).
// TDH2 label is SHA256(orgID); SecretIdentifier.Owner is the org id string. This is a separate binding from the
// owner-key path (EOA left-padded label + workflow owner address); both remain supported via their respective entrypoints.
func (h *Handler) EncryptSecretsForBrowserOrg(rawSecrets UpsertSecretsInputs, orgID string) ([]*vault.EncryptedSecret, error) {
pubKeyHex, err := h.fetchVaultMasterPublicKeyHex()
if err != nil {
return nil, err
}

label := sha256.Sum256([]byte(orgID))

encryptedSecrets := make([]*vault.EncryptedSecret, 0, len(rawSecrets))
for _, item := range rawSecrets {
cipherHex, err := encryptSecretWithLabel(item.Value, pubKeyHex, label)
if err != nil {
return nil, fmt.Errorf("failed to encrypt secret (key=%s ns=%s): %w", item.ID, item.Namespace, err)
}
secID := &vault.SecretIdentifier{
Key: item.ID,
Namespace: item.Namespace,
Owner: orgID,
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.

Is this the only difference compared to web3 flow or is there more? Can we reduce the amount of c/p code between those two functions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, I have consolidated the duplicate code. This is a good simplification.

}
encryptedSecrets = append(encryptedSecrets, &vault.EncryptedSecret{
Id: secID,
EncryptedValue: cipherHex,
})
}
return encryptedSecrets, nil
}

// encryptSecretWithLabel encrypts a secret using the vault master public key and the given label.
func encryptSecretWithLabel(secret, masterPublicKeyHex string, label [32]byte) (string, error) {
masterPublicKey := tdh2easy.PublicKey{}
masterPublicKeyBytes, err := hex.DecodeString(masterPublicKeyHex)
if err != nil {
Expand All @@ -294,9 +340,6 @@ func EncryptSecret(secret, masterPublicKeyHex string, ownerAddress string) (stri
return "", fmt.Errorf("failed to unmarshal master public key: %w", err)
}

addr := common.HexToAddress(ownerAddress) // canonical 20-byte address
var label [32]byte
copy(label[12:], addr.Bytes()) // left-pad with 12 zero bytes
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 are we getting rid of this code? If the other side expects 32 byte prefix in the payload, then should we keep it backwards compatible?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cipher, err := tdh2easy.EncryptWithLabel(&masterPublicKey, []byte(secret), label)
if err != nil {
return "", fmt.Errorf("failed to encrypt secret: %w", err)
Expand All @@ -308,6 +351,14 @@ func EncryptSecret(secret, masterPublicKeyHex string, ownerAddress string) (stri
return hex.EncodeToString(cipherBytes), nil
}

// EncryptSecret encrypts for the owner-key / web3 flow using a 32-byte label derived from the EOA (12 zero bytes + 20-byte address).
func EncryptSecret(secret, masterPublicKeyHex string, ownerAddress string) (string, error) {
addr := common.HexToAddress(ownerAddress) // canonical 20-byte address
var label [32]byte
copy(label[12:], addr.Bytes()) // left-pad with 12 zero bytes
return encryptSecretWithLabel(secret, masterPublicKeyHex, label)
}

func CalculateDigest[I any](r jsonrpc2.Request[I]) ([32]byte, error) {
b, err := json.Marshal(r.Params)
if err != nil {
Expand Down Expand Up @@ -344,15 +395,21 @@ func HexToBytes32(h string) ([32]byte, error) {
return out, nil
}

// Execute is shared for 'create' and 'update' (YAML-only).
// - MSIG => step 1: build request, save bundle, print instructions
// - EOA => build request, allowlist if needed, POST
// Execute implements secrets create and update from YAML (multisig bundle, owner-key with allowlist, or interactive org sign-in).
func (h *Handler) Execute(
inputs UpsertSecretsInputs,
method string,
duration time.Duration,
secretsAuth string,
) error {
if IsBrowserFlow(secretsAuth) {
return h.executeBrowserUpsert(context.Background(), inputs, method)
}

if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil {
return err
}

ui.Dim("Verifying ownership...")
if err := h.EnsureOwnerLinkedOrFail(); err != nil {
return err
Expand Down
Loading
Loading