From f25dc1e6da824118ab64f06955cdfad8a03bc415 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 25 Mar 2026 10:50:07 +0000 Subject: [PATCH 1/4] Add secret auth api request --- cmd/root.go | 5 - cmd/secrets/common/browser_flow.go | 147 +++++++++++++++++++++++++ cmd/secrets/common/handler.go | 96 ++++++++++++++-- cmd/secrets/delete/delete.go | 6 + cmd/secrets/execute/execute.go | 4 + cmd/secrets/list/list.go | 6 + cmd/secrets/secrets.go | 2 +- internal/settings/workflow_settings.go | 11 +- 8 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 cmd/secrets/common/browser_flow.go diff --git a/cmd/root.go b/cmd/root.go index f980bbc9..d70b735a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -503,11 +503,6 @@ func isLoadDeploymentRPC(cmd *cobra.Command) bool { "cre workflow delete": {}, "cre account link-key": {}, "cre account unlink-key": {}, - "cre secrets create": {}, - "cre secrets delete": {}, - "cre secrets execute": {}, - "cre secrets list": {}, - "cre secrets update": {}, } _, exists := includedCommands[cmd.CommandPath()] return exists diff --git a/cmd/secrets/common/browser_flow.go b/cmd/secrets/common/browser_flow.go new file mode 100644 index 00000000..1ea6679b --- /dev/null +++ b/cmd/secrets/common/browser_flow.go @@ -0,0 +1,147 @@ +package common + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + + "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 + 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) + gqlReq.Var("request", map[string]any{ + "codeChallenge": challenge, + "redirectUri": constants.AuthRedirectURI, + "requestDigest": digestHexString(digest), + "permission": perm, + }) + + 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 == "" { + 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 +} diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 48975140..de9f2f6f 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -3,6 +3,7 @@ package common import ( "context" "crypto/ecdsa" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -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.ValidateDeploymentRPCForChain(&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) { @@ -284,7 +290,73 @@ 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). +func (h *Handler) EncryptSecretsForBrowserOrg(rawSecrets UpsertSecretsInputs, orgID string) ([]*vault.EncryptedSecret, error) { + requestID := uuid.New().String() + getPublicKeyRequest := jsonrpc2.Request[vault.GetPublicKeyRequest]{ + Version: jsonrpc2.JsonRpcVersion, + ID: requestID, + Method: vaulttypes.MethodPublicKeyGet, + Params: &vault.GetPublicKeyRequest{}, + } + + reqBody, err := json.Marshal(getPublicKeyRequest) + if err != nil { + return nil, 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) + } + if status != http.StatusOK { + return nil, 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) + } + if rpcResp.Error != nil { + return nil, 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) + } + if rpcResp.ID != requestID { + return nil, 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) + } + if rpcResp.Result == nil || rpcResp.Result.PublicKey == "" { + return nil, fmt.Errorf("empty result in public key response") + } + + pubKeyHex := rpcResp.Result.PublicKey + 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, + } + 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 { @@ -294,9 +366,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 cipher, err := tdh2easy.EncryptWithLabel(&masterPublicKey, []byte(secret), label) if err != nil { return "", fmt.Errorf("failed to encrypt secret: %w", err) @@ -308,6 +377,13 @@ func EncryptSecret(secret, masterPublicKeyHex string, ownerAddress string) (stri return hex.EncodeToString(cipherBytes), nil } +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 { @@ -344,15 +420,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 diff --git a/cmd/secrets/delete/delete.go b/cmd/secrets/delete/delete.go index 573c64c2..050b16e7 100644 --- a/cmd/secrets/delete/delete.go +++ b/cmd/secrets/delete/delete.go @@ -108,6 +108,12 @@ func New(ctx *runtime.Context) *cobra.Command { // - MSIG step 1: build request, compute digest, write bundle, print steps // - EOA: allowlist if needed, then POST to gateway func Execute(h *common.Handler, inputs DeleteSecretsInputs, duration time.Duration, secretsAuth string) error { + if !common.IsBrowserFlow(secretsAuth) { + if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil { + return err + } + } + spinner := ui.NewSpinner() spinner.Start("Verifying ownership...") if err := h.EnsureOwnerLinkedOrFail(); err != nil { diff --git a/cmd/secrets/execute/execute.go b/cmd/secrets/execute/execute.go index 9ef16fa0..4525efb0 100644 --- a/cmd/secrets/execute/execute.go +++ b/cmd/secrets/execute/execute.go @@ -65,6 +65,10 @@ func New(ctx *runtime.Context) *cobra.Command { return fmt.Errorf("invalid bundle digest: %w", err) } + if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil { + return err + } + ownerAddr := ethcommon.HexToAddress(h.OwnerAddress) allowlisted, err := h.Wrc.IsRequestAllowlisted(ownerAddr, digest) diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index ba15e056..e5504c3f 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -79,6 +79,12 @@ func New(ctx *runtime.Context) *cobra.Command { // Execute performs: build request → (MSIG step 1 bundle OR EOA allowlist+post) → parse. func Execute(h *common.Handler, namespace string, duration time.Duration, secretsAuth string) error { + if !common.IsBrowserFlow(secretsAuth) { + if err := h.EnsureDeploymentRPCForOwnerKeySecrets(); err != nil { + return err + } + } + spinner := ui.NewSpinner() spinner.Start("Verifying ownership...") if err := h.EnsureOwnerLinkedOrFail(); err != nil { diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index 8ad618c9..836e5f84 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -32,7 +32,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { "Timeout for secrets operations (e.g. 30m, 2h, 48h).", ) - secretsCmd.PersistentFlags().String("secrets-auth", "owner-key-signing", "Auth flow for secrets operations (owner-key-signing, browser).") + secretsCmd.PersistentFlags().String("secrets-auth", "owner-key-signing", "Authentication mode: owner-key-signing (workflow owner) or browser (organization sign-in).") _ = secretsCmd.PersistentFlags().MarkHidden("secrets-auth") secretsCmd.AddCommand(create.New(runtimeContext)) diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index 1ac6665c..2f62f35c 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -151,7 +151,7 @@ func loadWorkflowSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Com } if registryChainName != "" { - if err := validateDeploymentRPC(&workflowSettings, registryChainName); err != nil { + if err := ValidateDeploymentRPCForChain(&workflowSettings, registryChainName); err != nil { return WorkflowSettings{}, errors.Wrap(err, "for target "+target) } } @@ -274,6 +274,15 @@ func ShouldSkipGetOwner(cmd *cobra.Command) bool { } } +// ValidateDeploymentRPCForChain ensures project settings define a valid RPC URL for the given chain +// (for example the workflow registry chain used for on-chain allowlisting). +func ValidateDeploymentRPCForChain(config *WorkflowSettings, chainName string) error { + if chainName == "" { + return nil + } + return validateDeploymentRPC(config, chainName) +} + func validateDeploymentRPC(config *WorkflowSettings, chainName string) error { deploymentRPCFound := false deploymentRPCURL := "" From 581522f957fe8a2c5de966fe3768e13af3c08413 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 25 Mar 2026 11:00:55 +0000 Subject: [PATCH 2/4] Add tests and refactor RPC check --- cmd/secrets/common/browser_flow_test.go | 44 +++++++++++++++++++++++++ cmd/secrets/common/handler.go | 2 +- internal/settings/workflow_settings.go | 16 +++------ 3 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 cmd/secrets/common/browser_flow_test.go diff --git a/cmd/secrets/common/browser_flow_test.go b/cmd/secrets/common/browser_flow_test.go new file mode 100644 index 00000000..b8c42429 --- /dev/null +++ b/cmd/secrets/common/browser_flow_test.go @@ -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) +} diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index de9f2f6f..4277160e 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -106,7 +106,7 @@ func NewHandler(ctx *runtime.Context, secretsFilePath string) (*Handler, error) // 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.ValidateDeploymentRPCForChain(&h.Settings.Workflow, h.EnvironmentSet.WorkflowRegistryChainName) + return settings.ValidateDeploymentRPC(&h.Settings.Workflow, h.EnvironmentSet.WorkflowRegistryChainName) } // ResolveInputs loads secrets from a YAML file. diff --git a/internal/settings/workflow_settings.go b/internal/settings/workflow_settings.go index 2f62f35c..fd43c4bf 100644 --- a/internal/settings/workflow_settings.go +++ b/internal/settings/workflow_settings.go @@ -150,10 +150,8 @@ func loadWorkflowSettings(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Com workflowSettings.RPCs[i].Url = resolved } - if registryChainName != "" { - if err := ValidateDeploymentRPCForChain(&workflowSettings, registryChainName); err != nil { - return WorkflowSettings{}, errors.Wrap(err, "for target "+target) - } + if err := ValidateDeploymentRPC(&workflowSettings, registryChainName); err != nil { + return WorkflowSettings{}, errors.Wrap(err, "for target "+target) } if err := validateSettings(&workflowSettings); err != nil { @@ -274,16 +272,12 @@ func ShouldSkipGetOwner(cmd *cobra.Command) bool { } } -// ValidateDeploymentRPCForChain ensures project settings define a valid RPC URL for the given chain -// (for example the workflow registry chain used for on-chain allowlisting). -func ValidateDeploymentRPCForChain(config *WorkflowSettings, chainName string) error { +// ValidateDeploymentRPC ensures project settings define a valid RPC URL for chainName (e.g. the workflow +// registry chain). It is a no-op when chainName is empty. Used during settings load and from secrets owner-key flows. +func ValidateDeploymentRPC(config *WorkflowSettings, chainName string) error { if chainName == "" { return nil } - return validateDeploymentRPC(config, chainName) -} - -func validateDeploymentRPC(config *WorkflowSettings, chainName string) error { deploymentRPCFound := false deploymentRPCURL := "" commonError := " - required to deploy CRE workflows" From 52a22222c38f6f4e77d7a6cbf7b98ec5470676f5 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 25 Mar 2026 12:54:59 +0000 Subject: [PATCH 3/4] Refactor duplicate code --- cmd/secrets/common/handler.go | 77 ++++++++++++----------------------- 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 4277160e..7908a322 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -226,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, @@ -238,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 { @@ -291,49 +300,14 @@ func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs) ([]*vault.Encry } // 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) { - requestID := uuid.New().String() - getPublicKeyRequest := jsonrpc2.Request[vault.GetPublicKeyRequest]{ - Version: jsonrpc2.JsonRpcVersion, - ID: requestID, - Method: vaulttypes.MethodPublicKeyGet, - Params: &vault.GetPublicKeyRequest{}, - } - - reqBody, err := json.Marshal(getPublicKeyRequest) + pubKeyHex, err := h.fetchVaultMasterPublicKeyHex() if err != nil { - return nil, 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) - } - if status != http.StatusOK { - return nil, 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) - } - if rpcResp.Error != nil { - return nil, 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) - } - if rpcResp.ID != requestID { - return nil, 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) - } - if rpcResp.Result == nil || rpcResp.Result.PublicKey == "" { - return nil, fmt.Errorf("empty result in public key response") + return nil, err } - pubKeyHex := rpcResp.Result.PublicKey label := sha256.Sum256([]byte(orgID)) encryptedSecrets := make([]*vault.EncryptedSecret, 0, len(rawSecrets)) @@ -377,6 +351,7 @@ func encryptSecretWithLabel(secret, masterPublicKeyHex string, label [32]byte) ( 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 From 5e91446fc83f663e39451991c8d4aaa5322921cb Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 25 Mar 2026 13:18:28 +0000 Subject: [PATCH 4/4] Bind to workflow owner when not nil --- cmd/secrets/common/browser_flow.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/secrets/common/browser_flow.go b/cmd/secrets/common/browser_flow.go index 1ea6679b..9e12d821 100644 --- a/cmd/secrets/common/browser_flow.go +++ b/cmd/secrets/common/browser_flow.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "strings" "github.com/google/uuid" "github.com/machinebox/graphql" @@ -111,12 +112,17 @@ func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecrets gqlClient := graphqlclient.New(h.Credentials, h.EnvironmentSet, h.Log) gqlReq := graphql.NewRequest(createVaultAuthURLMutation) - gqlReq.Var("request", map[string]any{ + 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 {