-
Notifications
You must be signed in to change notification settings - Fork 13
Add secret auth api request #324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 == "" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that is correct.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| 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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.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) { | ||
|
|
@@ -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, | ||
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not removed, the functionality was reduced significantly and then called here: https://github.com/smartcontractkit/cre-cli/pull/324/changes#diff-76f4c29977fe8625f8281841685d4522512859fe6b978c9d796f1ba8e49ef699L270 |
||
| cipher, err := tdh2easy.EncryptWithLabel(&masterPublicKey, []byte(secret), label) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to encrypt secret: %w", err) | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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