diff --git a/cmd/secrets/common/browser_flow.go b/cmd/secrets/common/browser_flow.go index 8fe264af..0526a7cd 100644 --- a/cmd/secrets/common/browser_flow.go +++ b/cmd/secrets/common/browser_flow.go @@ -63,6 +63,8 @@ func digestHexString(digest [32]byte) string { // exchanges the code for a short-lived vault JWT, and POSTs the same JSON-RPC body to the gateway with Bearer auth. // Login tokens in ~/.cre/cre.yaml are not modified; that session stays separate from this vault-only token. func (h *Handler) executeBrowserUpsert(ctx context.Context, inputs UpsertSecretsInputs, method string) error { + defer ZeroUpsertSecretValues(inputs) + if h.Credentials.AuthType == credentials.AuthTypeApiKey { return fmt.Errorf("this sign-in flow requires an interactive login; API keys are not supported") } diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 36f81ea6..3e780676 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" @@ -48,10 +49,17 @@ type UpsertSecretsInputs []SecretItem // SecretItem represents a single secret with its ID, value, and optional namespace. type SecretItem struct { ID string `json:"id" validate:"required"` - Value string `json:"value" validate:"required"` + Value []byte `json:"value" validate:"required"` Namespace string `json:"namespace"` } +// ZeroUpsertSecretValues overwrites secret payloads in memory. +func ZeroUpsertSecretValues(inputs UpsertSecretsInputs) { + for i := range inputs { + clear(inputs[i].Value) + } +} + type SecretsYamlConfig struct { SecretsNames map[string][]string `yaml:"secretsNames"` } @@ -161,13 +169,13 @@ func (h *Handler) ResolveInputs() (UpsertSecretsInputs, error) { if !ok { return nil, fmt.Errorf("environment variable %q for secret %q not found; please export it", envName, id) } - if !utf8.ValidString(envVal) { + if !utf8.Valid([]byte(envVal)) { return nil, fmt.Errorf("value for secret %q (env %q) contains invalid UTF-8", id, envName) } out = append(out, SecretItem{ ID: id, - Value: envVal, + Value: []byte(envVal), Namespace: "main", }) @@ -318,8 +326,10 @@ func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs, owner string) ( } encryptedSecrets := make([]*vault.EncryptedSecret, 0, len(rawSecrets)) - for _, item := range rawSecrets { + for i := range rawSecrets { + item := &rawSecrets[i] cipherHex, err := EncryptSecret(item.Value, pubKeyHex, owner) + clear(item.Value) if err != nil { return nil, fmt.Errorf("failed to encrypt secret (key=%s ns=%s): %w", item.ID, item.Namespace, err) } @@ -336,8 +346,40 @@ func (h *Handler) EncryptSecrets(rawSecrets UpsertSecretsInputs, owner string) ( return encryptedSecrets, nil } +// 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 i := range rawSecrets { + item := &rawSecrets[i] + cipherHex, err := encryptSecretWithLabel(item.Value, pubKeyHex, label) + clear(item.Value) + 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) { +func encryptSecretWithLabel(secret []byte, masterPublicKeyHex string, label [32]byte) (string, error) { masterPublicKey := tdh2easy.PublicKey{} masterPublicKeyBytes, err := hex.DecodeString(masterPublicKeyHex) if err != nil { @@ -347,7 +389,7 @@ func encryptSecretWithLabel(secret, masterPublicKeyHex string, label [32]byte) ( return "", fmt.Errorf("failed to unmarshal master public key: %w", err) } - cipher, err := tdh2easy.EncryptWithLabel(&masterPublicKey, []byte(secret), label) + cipher, err := tdh2easy.EncryptWithLabel(&masterPublicKey, secret, label) if err != nil { return "", fmt.Errorf("failed to encrypt secret: %w", err) } @@ -359,7 +401,7 @@ func encryptSecretWithLabel(secret, masterPublicKeyHex string, label [32]byte) ( } // 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) { +func EncryptSecret(secret []byte, 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 @@ -410,6 +452,8 @@ func (h *Handler) Execute( duration time.Duration, secretsAuth string, ) error { + defer ZeroUpsertSecretValues(inputs) + h.execCtx = ctx if IsBrowserFlow(secretsAuth) { return h.executeBrowserUpsert(ctx, inputs, method) diff --git a/cmd/secrets/common/handler_test.go b/cmd/secrets/common/handler_test.go index 4a2d14b0..6bcc2c20 100644 --- a/cmd/secrets/common/handler_test.go +++ b/cmd/secrets/common/handler_test.go @@ -69,8 +69,8 @@ func TestEncryptSecrets(t *testing.T) { } raw := UpsertSecretsInputs{ - {ID: "test-secret-1", Value: "value1", Namespace: "ns1"}, - {ID: "test-secret-2", Value: "another-value", Namespace: "ns2"}, + {ID: "test-secret-1", Value: []byte("value1"), Namespace: "ns1"}, + {ID: "test-secret-2", Value: []byte("another-value"), Namespace: "ns2"}, } enc, err := h.EncryptSecrets(raw, "0xabc") @@ -102,7 +102,7 @@ func TestEncryptSecrets(t *testing.T) { }, } - enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: "v", Namespace: "n"}}, "0xabc") + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") require.Error(t, err) require.Nil(t, enc) require.Contains(t, err.Error(), "gateway POST failed") @@ -128,7 +128,7 @@ func TestEncryptSecrets(t *testing.T) { }, } - enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: "v", Namespace: "n"}}, "0xabc") + enc, err := h.EncryptSecrets(UpsertSecretsInputs{{ID: "s", Value: []byte("v"), Namespace: "n"}}, "0xabc") require.Error(t, err) require.Nil(t, enc) require.Contains(t, err.Error(), "vault public key fetch error") @@ -227,7 +227,7 @@ func TestEncryptSecrets_UsesWorkflowOwnerAddress(t *testing.T) { h.OwnerAddress = "0xabc" enc, err := h.EncryptSecrets(UpsertSecretsInputs{ - {ID: "secret-1", Value: "val1", Namespace: "main"}, + {ID: "secret-1", Value: []byte("val1"), Namespace: "main"}, }, "0xabc") require.NoError(t, err) require.Len(t, enc, 1) diff --git a/cmd/secrets/create/create.go b/cmd/secrets/create/create.go index 8209b103..31f1a50d 100644 --- a/cmd/secrets/create/create.go +++ b/cmd/secrets/create/create.go @@ -66,6 +66,7 @@ func New(ctx *runtime.Context) *cobra.Command { if err != nil { return err } + defer common.ZeroUpsertSecretValues(inputs) if err := h.ValidateInputs(inputs); err != nil { return err diff --git a/cmd/secrets/update/update.go b/cmd/secrets/update/update.go index a7bc84ef..de776004 100644 --- a/cmd/secrets/update/update.go +++ b/cmd/secrets/update/update.go @@ -67,6 +67,7 @@ func New(ctx *runtime.Context) *cobra.Command { if err != nil { return err } + defer common.ZeroUpsertSecretValues(inputs) if err := h.ValidateInputs(inputs); err != nil { return err