diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 40f8dfda..0d84e00c 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -352,7 +352,8 @@ func (h *Handler) optionalCapRegVaultPublicKeyHex(ctx context.Context) (key stri } // vaultMasterPublicKeyHex loads the vault master public key from the gateway and, when CapabilitiesRegistry -// RPC is configured, verifies it matches the on-chain commitment before encryption. +// RPC is configured, verifies it matches the on-chain commitment before encryption. When validation is +// enabled, TOFU pinning detects gateway key changes that are not reflected on-chain. func (h *Handler) vaultMasterPublicKeyHex(ctx context.Context) (string, error) { gatewayKey, err := h.fetchVaultMasterPublicKeyHex() if err != nil { @@ -363,12 +364,31 @@ func (h *Handler) vaultMasterPublicKeyHex(ctx context.Context) (string, error) { if err != nil { return "", err } - if !compare { + if !compare || h.SkipVaultValidation() { return gatewayKey, nil } + + gatewayFP, err := tenantctx.VaultPublicKeyFingerprint(gatewayKey) + if err != nil { + return "", err + } + onChainFP, err := tenantctx.VaultPublicKeyFingerprint(onChainKey) + if err != nil { + return "", err + } + + if err := h.verifyVaultKeyTOFU(gatewayFP, onChainFP); err != nil { + return "", err + } + if !strings.EqualFold(gatewayKey, onChainKey) { return "", fmt.Errorf("vault public key from gateway does not match CapabilitiesRegistry") } + + if err := h.persistVaultKeyPin(gatewayFP); err != nil { + return "", err + } + return gatewayKey, nil } diff --git a/cmd/secrets/common/handler_test.go b/cmd/secrets/common/handler_test.go index b7ad82ce..18827023 100644 --- a/cmd/secrets/common/handler_test.go +++ b/cmd/secrets/common/handler_test.go @@ -142,7 +142,10 @@ func TestEncryptSecrets(t *testing.T) { }) t.Run("success - gateway key matches CapabilitiesRegistry when RPC is configured", func(t *testing.T) { + pinTestHome(t) h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" h.OwnerAddress = "0xabc" attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) attachMockVaultDONResolver(t, h, vaultPublicKeyHex) @@ -153,7 +156,10 @@ func TestEncryptSecrets(t *testing.T) { }) t.Run("failure - gateway key does not match CapabilitiesRegistry", func(t *testing.T) { + pinTestHome(t) h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" h.OwnerAddress = "0xabc" attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) attachMockVaultDONResolver(t, h, "deadbeef") diff --git a/cmd/secrets/common/vault_tofu.go b/cmd/secrets/common/vault_tofu.go new file mode 100644 index 00000000..5c035986 --- /dev/null +++ b/cmd/secrets/common/vault_tofu.go @@ -0,0 +1,68 @@ +package common + +import ( + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common/gateway" + "github.com/smartcontractkit/cre-cli/internal/creconfig" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func (h *Handler) vaultKeyPinScope() (tenantctx.VaultKeyPinScope, error) { + if h.TenantContext == nil { + return tenantctx.VaultKeyPinScope{}, fmt.Errorf("tenant context is not available; run `cre login` to refresh") + } + if h.TenantContext.CapabilitiesRegistry == nil { + return tenantctx.VaultKeyPinScope{}, fmt.Errorf("capabilities registry is not configured in your user context; run `cre login` to refresh") + } + if h.EnvironmentSet == nil { + return tenantctx.VaultKeyPinScope{}, fmt.Errorf("environment is not configured") + } + + envName := strings.TrimSpace(h.EnvironmentSet.EnvName) + if envName == "" { + envName = "production" + } + + return tenantctx.VaultKeyPinScope{ + EnvName: envName, + TenantID: h.TenantContext.TenantID, + CapRegChainSelector: h.TenantContext.CapabilitiesRegistry.ChainSelector, + CapRegAddress: h.TenantContext.CapabilitiesRegistry.Address, + VaultGatewayURL: gateway.ResolveVaultGatewayURL(h.TenantContext, h.EnvironmentSet), + }, nil +} + +func (h *Handler) verifyVaultKeyTOFU(gatewayFP, onChainFP string) error { + scope, err := h.vaultKeyPinScope() + if err != nil { + return err + } + + pinnedFP, ok, err := tenantctx.LoadVaultKeyPin(scope) + if err != nil { + return fmt.Errorf("load vault public key pin: %w", err) + } + if !ok || tenantctx.FingerprintsMatch(pinnedFP, gatewayFP) { + return nil + } + if tenantctx.FingerprintsMatch(pinnedFP, onChainFP) { + return fmt.Errorf( + "vault public key from gateway changed without a matching on-chain update; remove %s to re-trust after verifying the gateway", + creconfig.FilePathHint(tenantctx.VaultKeyPinsFile), + ) + } + return nil +} + +func (h *Handler) persistVaultKeyPin(gatewayFP string) error { + scope, err := h.vaultKeyPinScope() + if err != nil { + return err + } + if err := tenantctx.SaveVaultKeyPin(scope, gatewayFP); err != nil { + return fmt.Errorf("persist vault public key pin: %w", err) + } + return nil +} diff --git a/cmd/secrets/common/vault_tofu_test.go b/cmd/secrets/common/vault_tofu_test.go new file mode 100644 index 00000000..90a13cf0 --- /dev/null +++ b/cmd/secrets/common/vault_tofu_test.go @@ -0,0 +1,121 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func pinTestHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) +} + +func tofuTestTenantContext() *tenantctx.EnvironmentContext { + return &tenantctx.EnvironmentContext{ + TenantID: "tenant-1", + VaultGatewayURL: "https://gateway.example.com/", + CapabilitiesRegistry: &tenantctx.OnChainContract{ + ChainSelector: 16015286601757825753, + Address: "0x7f3191EaF73429177bAB3bAc5c36Ed2D5E39985f", + }, + } +} + +func TestVaultMasterPublicKeyHex_TOFUFirstPin(t *testing.T) { + pinTestHome(t) + + h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, vaultPublicKeyHex) + + key, err := h.vaultMasterPublicKeyHex(h.execCtx) + require.NoError(t, err) + require.Equal(t, vaultPublicKeyHex, key) + + fp, ok, err := tenantctx.LoadVaultKeyPin(tenantctx.VaultKeyPinScope{ + EnvName: "staging", + TenantID: "tenant-1", + CapRegChainSelector: h.TenantContext.CapabilitiesRegistry.ChainSelector, + CapRegAddress: h.TenantContext.CapabilitiesRegistry.Address, + VaultGatewayURL: h.TenantContext.VaultGatewayURL, + }) + require.NoError(t, err) + require.True(t, ok) + require.NotEmpty(t, fp) +} + +func TestVaultMasterPublicKeyHex_TOFUAbortsGatewayChangeWithoutOnChainUpdate(t *testing.T) { + pinTestHome(t) + + h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, vaultPublicKeyHex) + + _, err := h.vaultMasterPublicKeyHex(h.execCtx) + require.NoError(t, err) + + attachGatewayPublicKeyMock(t, h, "deadbeef") + _, err = h.vaultMasterPublicKeyHex(h.execCtx) + require.ErrorContains(t, err, "changed without a matching on-chain update") +} + +func TestVaultMasterPublicKeyHex_TOFUAllowsOnChainRotation(t *testing.T) { + pinTestHome(t) + + h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, vaultPublicKeyHex) + + _, err := h.vaultMasterPublicKeyHex(h.execCtx) + require.NoError(t, err) + + rotatedKey := "cafebabe" + attachGatewayPublicKeyMock(t, h, rotatedKey) + attachMockVaultDONResolver(t, h, rotatedKey) + + key, err := h.vaultMasterPublicKeyHex(h.execCtx) + require.NoError(t, err) + require.Equal(t, rotatedKey, key) + + fp, ok, err := tenantctx.LoadVaultKeyPin(tenantctx.VaultKeyPinScope{ + EnvName: "staging", + TenantID: "tenant-1", + CapRegChainSelector: h.TenantContext.CapabilitiesRegistry.ChainSelector, + CapRegAddress: h.TenantContext.CapabilitiesRegistry.Address, + VaultGatewayURL: h.TenantContext.VaultGatewayURL, + }) + require.NoError(t, err) + require.True(t, ok) + + rotatedFP, err := tenantctx.VaultPublicKeyFingerprint(rotatedKey) + require.NoError(t, err) + require.True(t, tenantctx.FingerprintsMatch(fp, rotatedFP)) +} + +func TestVaultMasterPublicKeyHex_SkipsTOFUWhenValidationOptedOut(t *testing.T) { + pinTestHome(t) + + h, _, _ := newMockHandler(t) + h.TenantContext = tofuTestTenantContext() + h.EnvironmentSet.EnvName = "staging" + attachGatewayPublicKeyMock(t, h, vaultPublicKeyHex) + attachMockVaultDONResolver(t, h, "deadbeef") + h.skipVaultValidation = true + + key, err := h.vaultMasterPublicKeyHex(h.execCtx) + require.NoError(t, err) + require.Equal(t, vaultPublicKeyHex, key) + + _, ok, err := tenantctx.LoadVaultKeyPin(tenantctx.VaultKeyPinScope{EnvName: "staging"}) + require.NoError(t, err) + require.False(t, ok) +} diff --git a/internal/tenantctx/vault_key_pin.go b/internal/tenantctx/vault_key_pin.go new file mode 100644 index 00000000..7f17af2d --- /dev/null +++ b/internal/tenantctx/vault_key_pin.go @@ -0,0 +1,155 @@ +package tenantctx + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" + + "github.com/smartcontractkit/cre-cli/internal/creconfig" +) + +const VaultKeyPinsFile = "vault_key_pins.yaml" + +// VaultKeyPinScope identifies the tenant vault trust anchor for TOFU pinning. +type VaultKeyPinScope struct { + EnvName string + TenantID string + CapRegChainSelector uint64 + CapRegAddress string + VaultGatewayURL string +} + +// VaultKeyPin is persisted locally after a successful on-chain vault key match. +type VaultKeyPin struct { + TenantID string `yaml:"tenant_id"` + CapRegChainSelector uint64 `yaml:"cap_reg_chain_selector"` + CapRegAddress string `yaml:"cap_reg_address"` + VaultGatewayURL string `yaml:"vault_gateway_url"` + PublicKeyFingerprint string `yaml:"public_key_fingerprint"` +} + +// VaultPublicKeyFingerprint returns the SHA-256 hex digest of the vault master public key bytes. +func VaultPublicKeyFingerprint(publicKeyHex string) (string, error) { + hexKey := strings.TrimPrefix(strings.TrimSpace(publicKeyHex), "0x") + if hexKey == "" { + return "", fmt.Errorf("vault public key is empty") + } + raw, err := hex.DecodeString(hexKey) + if err != nil { + return "", fmt.Errorf("invalid vault public key hex: %w", err) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +// FingerprintsMatch compares two vault public key fingerprints in constant time. +func FingerprintsMatch(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(strings.ToLower(a)), []byte(strings.ToLower(b))) == 1 +} + +// LoadVaultKeyPin reads the pinned fingerprint for scope when the stored metadata matches. +func LoadVaultKeyPin(scope VaultKeyPinScope) (fingerprint string, ok bool, err error) { + path, err := creconfig.FilePath(VaultKeyPinsFile) + if err != nil { + return "", false, err + } + return loadVaultKeyPinFromPath(path, scope) +} + +func loadVaultKeyPinFromPath(path string, scope VaultKeyPinScope) (string, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return "", false, fmt.Errorf("read %s: %w", VaultKeyPinsFile, err) + } + + var pins map[string]*VaultKeyPin + if err := yaml.Unmarshal(data, &pins); err != nil { + return "", false, fmt.Errorf("parse %s: %w", VaultKeyPinsFile, err) + } + + pin := pins[strings.ToUpper(strings.TrimSpace(scope.EnvName))] + if pin == nil || pin.PublicKeyFingerprint == "" { + return "", false, nil + } + if !pinMatchesScope(pin, scope) { + return "", false, nil + } + return pin.PublicKeyFingerprint, true, nil +} + +// SaveVaultKeyPin persists the fingerprint for scope, replacing any prior pin for the environment. +func SaveVaultKeyPin(scope VaultKeyPinScope, fingerprint string) error { + path, err := creconfig.FilePath(VaultKeyPinsFile) + if err != nil { + return err + } + return saveVaultKeyPinToPath(path, scope, fingerprint) +} + +func saveVaultKeyPinToPath(path string, scope VaultKeyPinScope, fingerprint string) error { + if strings.TrimSpace(fingerprint) == "" { + return fmt.Errorf("vault public key fingerprint is empty") + } + + pins := map[string]*VaultKeyPin{} + if data, err := os.ReadFile(path); err == nil { + if err := yaml.Unmarshal(data, &pins); err != nil { + return fmt.Errorf("parse %s: %w", VaultKeyPinsFile, err) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", VaultKeyPinsFile, err) + } + + envName := strings.ToUpper(strings.TrimSpace(scope.EnvName)) + pins[envName] = &VaultKeyPin{ + TenantID: scope.TenantID, + CapRegChainSelector: scope.CapRegChainSelector, + CapRegAddress: strings.TrimSpace(scope.CapRegAddress), + VaultGatewayURL: strings.TrimSpace(scope.VaultGatewayURL), + PublicKeyFingerprint: strings.ToLower(strings.TrimSpace(fingerprint)), + } + + out, err := yaml.Marshal(pins) + if err != nil { + return fmt.Errorf("marshal %s: %w", VaultKeyPinsFile, err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, out, 0o600); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) + } + return nil +} + +func pinMatchesScope(pin *VaultKeyPin, scope VaultKeyPinScope) bool { + if pin == nil { + return false + } + if pin.TenantID != scope.TenantID { + return false + } + if pin.CapRegChainSelector != scope.CapRegChainSelector { + return false + } + if !strings.EqualFold(pin.CapRegAddress, scope.CapRegAddress) { + return false + } + return strings.TrimSpace(pin.VaultGatewayURL) == strings.TrimSpace(scope.VaultGatewayURL) +} diff --git a/internal/tenantctx/vault_key_pin_test.go b/internal/tenantctx/vault_key_pin_test.go new file mode 100644 index 00000000..a497e754 --- /dev/null +++ b/internal/tenantctx/vault_key_pin_test.go @@ -0,0 +1,84 @@ +package tenantctx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVaultPublicKeyFingerprint(t *testing.T) { + fp1, err := VaultPublicKeyFingerprint("deadbeef") + require.NoError(t, err) + fp2, err := VaultPublicKeyFingerprint("0xDEADbeef") + require.NoError(t, err) + require.True(t, FingerprintsMatch(fp1, fp2)) + require.Len(t, fp1, 64) +} + +func TestSaveAndLoadVaultKeyPin(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, VaultKeyPinsFile) + + scope := VaultKeyPinScope{ + EnvName: "staging", + TenantID: "tenant-1", + CapRegChainSelector: 16015286601757825753, + CapRegAddress: "0xCapReg", + VaultGatewayURL: "https://gateway.example.com/", + } + + require.NoError(t, saveVaultKeyPinToPath(path, scope, "abc123")) + + fp, ok, err := loadVaultKeyPinFromPath(path, scope) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "abc123", fp) + + otherScope := scope + otherScope.TenantID = "other-tenant" + _, ok, err = loadVaultKeyPinFromPath(path, otherScope) + require.NoError(t, err) + require.False(t, ok) +} + +func TestLoadVaultKeyPin_MissingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, VaultKeyPinsFile) + + _, ok, err := loadVaultKeyPinFromPath(path, VaultKeyPinScope{EnvName: "staging"}) + require.NoError(t, err) + require.False(t, ok) +} + +func TestSaveVaultKeyPin_UsesCREDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + scope := VaultKeyPinScope{ + EnvName: "PRODUCTION", + TenantID: "tenant-1", + CapRegChainSelector: 1, + CapRegAddress: "0xabc", + VaultGatewayURL: "https://gateway.example.com/", + } + + require.NoError(t, SaveVaultKeyPin(scope, "feedface")) + + fp, ok, err := LoadVaultKeyPin(VaultKeyPinScope{ + EnvName: "production", + TenantID: "tenant-1", + CapRegChainSelector: 1, + CapRegAddress: "0xabc", + VaultGatewayURL: "https://gateway.example.com/", + }) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "feedface", fp) + + pinPath, err := filepath.Abs(filepath.Join(home, ".cre", VaultKeyPinsFile)) + require.NoError(t, err) + _, err = os.Stat(pinPath) + require.NoError(t, err) +}