Repository: ObolNetwork/charon

Issue:
Add TLS/HTTPS to the validatorAPI
## Problem to be solved

As part of a secure validating stack, the comms between a VC and charon should be encrypted. 

## Proposed solution
Support TLS on the validator API. This might involve allowing an operator to specify a certificate authority file and a signing key for charon to use, or it might be another approach. This model is used by prysm and vouch, others might use well known CAs like the internet ones instead of locally signed ones. 

### Out of Scope

If there is anything to highlight as out of scope for this issue, please outline it here.


Hints from issue comments:
@corverroos: This adds a bunch of complexity while it can easily be achieved on the devops side via sidecars. Also user research probably required to motivate this.

Solution patch:
diff --git a/app/app.go b/app/app.go
index 9123557a..23a96012 100644
--- a/app/app.go
+++ b/app/app.go
@@ -104,6 +104,8 @@ type Config struct {
 	ExecutionEngineAddr         string
 	Graffiti                    []string
 	GraffitiDisableClientAppend bool
+	VCTLSCertFile               string
+	VCTLSKeyFile                string
 
 	TestConfig TestConfig
 }
@@ -527,7 +529,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 		return err
 	}
 
-	if err := wireVAPIRouter(ctx, life, conf.ValidatorAPIAddr, eth2Cl, vapi, vapiCalls, conf.BuilderAPI); err != nil {
+	if err := wireVAPIRouter(ctx, life, conf.ValidatorAPIAddr, eth2Cl, vapi, vapiCalls, &conf); err != nil {
 		return err
 	}
 
@@ -1041,9 +1043,9 @@ func createMockValidators(pubkeys []eth2p0.BLSPubKey) beaconmock.ValidatorSet {
 
 // wireVAPIRouter constructs the validator API router and registers it with the life cycle manager.
 func wireVAPIRouter(ctx context.Context, life *lifecycle.Manager, vapiAddr string, eth2Cl eth2wrap.Client,
-	handler validatorapi.Handler, vapiCalls func(), builderEnabled bool,
+	handler validatorapi.Handler, vapiCalls func(), conf *Config,
 ) error {
-	vrouter, err := validatorapi.NewRouter(ctx, handler, eth2Cl, builderEnabled)
+	vrouter, err := validatorapi.NewRouter(ctx, handler, eth2Cl, conf.BuilderAPI)
 	if err != nil {
 		return errors.Wrap(err, "new monitoring server")
 	}
@@ -1057,7 +1059,15 @@ func wireVAPIRouter(ctx context.Context, life *lifecycle.Manager, vapiAddr strin
 		ReadHeaderTimeout: time.Second,
 	}
 
-	life.RegisterStart(lifecycle.AsyncBackground, lifecycle.StartValidatorAPI, httpServeHook(server.ListenAndServe))
+	if conf.VCTLSCertFile != "" && conf.VCTLSKeyFile != "" {
+		listenAndServeTLS := func() error {
+			return server.ListenAndServeTLS(conf.VCTLSCertFile, conf.VCTLSKeyFile)
+		}
+		life.RegisterStart(lifecycle.AsyncBackground, lifecycle.StartValidatorAPI, httpServeHook(listenAndServeTLS))
+	} else {
+		life.RegisterStart(lifecycle.AsyncBackground, lifecycle.StartValidatorAPI, httpServeHook(server.ListenAndServe))
+	}
+
 	life.RegisterStop(lifecycle.StopValidatorAPI, lifecycle.HookFunc(server.Shutdown))
 
 	return nil
diff --git a/app/disk.go b/app/disk.go
index 4665640e..2f008802 100644
--- a/app/disk.go
+++ b/app/disk.go
@@ -4,6 +4,7 @@ package app
 
 import (
 	"context"
+	"os"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth1wrap"
@@ -48,3 +49,13 @@ func loadClusterManifest(ctx context.Context, conf Config, eth1Cl eth1wrap.EthCl
 
 	return cluster, nil
 }
+
+// FileExists checks if a file exists at the given path.
+func FileExists(path string) bool {
+	_, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		return false
+	}
+
+	return err == nil
+}
diff --git a/app/obolapi/publish.go b/app/obolapi/publish.go
index 8c56e7eb..8ff3f6b6 100644
--- a/app/obolapi/publish.go
+++ b/app/obolapi/publish.go
@@ -98,5 +98,6 @@ func (c Client) SignTermsAndConditions(ctx context.Context, userAddr string, for
 
 	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
 	defer cancel()
+
 	return httpPost(ctx, addr, r, headers)
 }
diff --git a/cmd/createcluster.go b/cmd/createcluster.go
index 842b86cf..8cd7eabb 100644
--- a/cmd/createcluster.go
+++ b/cmd/createcluster.go
@@ -189,7 +189,8 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro
 	// If SplitKeys wasn't set, we wouldn't have reached this part of code because validateCreateConfig()
 	// would've already errored.
 	if conf.SplitKeys {
-		secrets, err = getKeys(conf.SplitKeysDir)
+		useSequencedKeys := len(conf.WithdrawalAddrs) > 1
+		secrets, err = getKeys(conf.SplitKeysDir, useSequencedKeys)
 		if err != nil {
 			return err
 		}
@@ -591,7 +592,7 @@ func writeWarning(w io.Writer) {
 }
 
 // getKeys fetches secret keys from splitKeysDir.
-func getKeys(splitKeysDir string) ([]tbls.PrivateKey, error) {
+func getKeys(splitKeysDir string, useSequencedKeys bool) ([]tbls.PrivateKey, error) {
 	if splitKeysDir == "" {
 		return nil, errors.New("--split-keys-dir required when splitting keys")
 	}
@@ -601,7 +602,11 @@ func getKeys(splitKeysDir string) ([]tbls.PrivateKey, error) {
 		return nil, err
 	}
 
-	return files.SequencedKeys()
+	if useSequencedKeys {
+		return files.SequencedKeys()
+	}
+
+	return files.Keys(), nil
 }
 
 // generateKeys generates numDVs amount of tbls.PrivateKeys.
diff --git a/cmd/run.go b/cmd/run.go
index 49ea7076..f29d6910 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -102,6 +102,8 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) {
 	cmd.Flags().StringVar(&config.ExecutionEngineAddr, "execution-client-rpc-endpoint", "", "The address of the execution engine JSON-RPC API.")
 	cmd.Flags().StringSliceVar(&config.Graffiti, "graffiti", nil, "Comma-separated list or single graffiti string to include in block proposals. List maps to validator's public key in cluster lock. Appends \"OB<CL_TYPE>\" suffix to graffiti. Maximum 28 bytes per graffiti.")
 	cmd.Flags().BoolVar(&config.GraffitiDisableClientAppend, "graffiti-disable-client-append", false, "Disables appending \"OB<CL_TYPE>\" suffix to graffiti. Increases maximum bytes per graffiti to 32.")
+	cmd.Flags().StringVar(&config.VCTLSCertFile, "vc-tls-cert-file", "", "The path to the TLS certificate file used by charon for secure communication with the validator client.")
+	cmd.Flags().StringVar(&config.VCTLSKeyFile, "vc-tls-public-key-file", "", "The path to the TLS public key file used by the validator client for secure communication with charon.")
 
 	wrapPreRunE(cmd, func(cc *cobra.Command, _ []string) error {
 		if len(config.BeaconNodeAddrs) == 0 && !config.SimnetBMock {
@@ -113,8 +115,7 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) {
 		if len(config.JaegerAddr) > 0 || len(config.JaegerService) > 0 {
 			log.Warn(cc.Context(), "Jaeger flags are disabled and will be removed in a future release", nil)
 		}
-		err := eth2util.ValidateBeaconNodeHeaders(config.BeaconNodeHeaders)
-		if err != nil {
+		if err := eth2util.ValidateBeaconNodeHeaders(config.BeaconNodeHeaders); err != nil {
 			return err
 		}
 		maxGraffitiBytes := 28
@@ -126,6 +127,15 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) {
 				return errors.New("graffiti string length is greater than maximum size")
 			}
 		}
+		if (config.VCTLSCertFile == "" && config.VCTLSKeyFile != "") || (config.VCTLSCertFile != "" && config.VCTLSKeyFile == "") {
+			return errors.New("both vc-tls-cert-file and vc-tls-public-key-file must be set or both must be empty")
+		}
+		if config.VCTLSCertFile != "" && !app.FileExists(config.VCTLSCertFile) {
+			return errors.New("file vc-tls-cert-file does not exist", z.Str("file", config.VCTLSCertFile))
+		}
+		if config.VCTLSKeyFile != "" && !app.FileExists(config.VCTLSKeyFile) {
+			return errors.New("file vc-tls-public-key-file does not exist", z.Str("file", config.VCTLSKeyFile))
+		}
 
 		return nil
 	})
diff --git a/core/validatorapi/mocks/handler.go b/core/validatorapi/mocks/handler.go
new file mode 100644
index 00000000..4f12d256
--- /dev/null
+++ b/core/validatorapi/mocks/handler.go
@@ -0,0 +1,611 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+// Code generated by mockery v2.53.3. DO NOT EDIT.
+
+package mocks
+
+import (
+	api "github.com/attestantio/go-eth2-client/api"
+	altair "github.com/attestantio/go-eth2-client/spec/altair"
+
+	context "context"
+
+	eth2exp "github.com/obolnetwork/charon/eth2util/eth2exp"
+
+	mock "github.com/stretchr/testify/mock"
+
+	phase0 "github.com/attestantio/go-eth2-client/spec/phase0"
+
+	spec "github.com/attestantio/go-eth2-client/spec"
+
+	v1 "github.com/attestantio/go-eth2-client/api/v1"
+)
+
+// Handler is an autogenerated mock type for the Handler type
+type Handler struct {
+	mock.Mock
+}
+
+// AggregateAttestation provides a mock function with given fields: ctx, opts
+func (_m *Handler) AggregateAttestation(ctx context.Context, opts *api.AggregateAttestationOpts) (*api.Response[*phase0.Attestation], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AggregateAttestation")
+	}
+
+	var r0 *api.Response[*phase0.Attestation]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*phase0.Attestation], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AggregateAttestationOpts) *api.Response[*phase0.Attestation]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*phase0.Attestation])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.AggregateAttestationOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// AggregateAttestationV2 provides a mock function with given fields: ctx, opts
+func (_m *Handler) AggregateAttestationV2(ctx context.Context, opts *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AggregateAttestationV2")
+	}
+
+	var r0 *api.Response[*spec.VersionedAttestation]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AggregateAttestationOpts) *api.Response[*spec.VersionedAttestation]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*spec.VersionedAttestation])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.AggregateAttestationOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// AggregateBeaconCommitteeSelections provides a mock function with given fields: ctx, partialSelections
+func (_m *Handler) AggregateBeaconCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
+	ret := _m.Called(ctx, partialSelections)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AggregateBeaconCommitteeSelections")
+	}
+
+	var r0 []*eth2exp.BeaconCommitteeSelection
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error)); ok {
+		return rf(ctx, partialSelections)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) []*eth2exp.BeaconCommitteeSelection); ok {
+		r0 = rf(ctx, partialSelections)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*eth2exp.BeaconCommitteeSelection)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) error); ok {
+		r1 = rf(ctx, partialSelections)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// AggregateSyncCommitteeSelections provides a mock function with given fields: ctx, partialSelections
+func (_m *Handler) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
+	ret := _m.Called(ctx, partialSelections)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AggregateSyncCommitteeSelections")
+	}
+
+	var r0 []*eth2exp.SyncCommitteeSelection
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error)); ok {
+		return rf(ctx, partialSelections)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) []*eth2exp.SyncCommitteeSelection); ok {
+		r0 = rf(ctx, partialSelections)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*eth2exp.SyncCommitteeSelection)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.SyncCommitteeSelection) error); ok {
+		r1 = rf(ctx, partialSelections)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// AttestationData provides a mock function with given fields: ctx, opts
+func (_m *Handler) AttestationData(ctx context.Context, opts *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AttestationData")
+	}
+
+	var r0 *api.Response[*phase0.AttestationData]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AttestationDataOpts) *api.Response[*phase0.AttestationData]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*phase0.AttestationData])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.AttestationDataOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// AttesterDuties provides a mock function with given fields: ctx, opts
+func (_m *Handler) AttesterDuties(ctx context.Context, opts *api.AttesterDutiesOpts) (*api.Response[[]*v1.AttesterDuty], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for AttesterDuties")
+	}
+
+	var r0 *api.Response[[]*v1.AttesterDuty]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AttesterDutiesOpts) (*api.Response[[]*v1.AttesterDuty], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.AttesterDutiesOpts) *api.Response[[]*v1.AttesterDuty]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[[]*v1.AttesterDuty])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.AttesterDutiesOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// NodeVersion provides a mock function with given fields: ctx, opts
+func (_m *Handler) NodeVersion(ctx context.Context, opts *api.NodeVersionOpts) (*api.Response[string], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for NodeVersion")
+	}
+
+	var r0 *api.Response[string]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) *api.Response[string]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[string])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.NodeVersionOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Proposal provides a mock function with given fields: ctx, opts
+func (_m *Handler) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Proposal")
+	}
+
+	var r0 *api.Response[*api.VersionedProposal]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) *api.Response[*api.VersionedProposal]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*api.VersionedProposal])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.ProposalOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ProposerConfig provides a mock function with given fields: ctx
+func (_m *Handler) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
+	ret := _m.Called(ctx)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ProposerConfig")
+	}
+
+	var r0 *eth2exp.ProposerConfigResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context) (*eth2exp.ProposerConfigResponse, error)); ok {
+		return rf(ctx)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context) *eth2exp.ProposerConfigResponse); ok {
+		r0 = rf(ctx)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*eth2exp.ProposerConfigResponse)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
+		r1 = rf(ctx)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// ProposerDuties provides a mock function with given fields: ctx, opts
+func (_m *Handler) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOpts) (*api.Response[[]*v1.ProposerDuty], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ProposerDuties")
+	}
+
+	var r0 *api.Response[[]*v1.ProposerDuty]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposerDutiesOpts) (*api.Response[[]*v1.ProposerDuty], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposerDutiesOpts) *api.Response[[]*v1.ProposerDuty]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[[]*v1.ProposerDuty])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.ProposerDutiesOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SubmitAggregateAttestations provides a mock function with given fields: ctx, aggregateAndProofs
+func (_m *Handler) SubmitAggregateAttestations(ctx context.Context, aggregateAndProofs []*phase0.SignedAggregateAndProof) error {
+	ret := _m.Called(ctx, aggregateAndProofs)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitAggregateAttestations")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*phase0.SignedAggregateAndProof) error); ok {
+		r0 = rf(ctx, aggregateAndProofs)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitAggregateAttestationsV2 provides a mock function with given fields: ctx, opts
+func (_m *Handler) SubmitAggregateAttestationsV2(ctx context.Context, opts *api.SubmitAggregateAttestationsOpts) error {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitAggregateAttestationsV2")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitAggregateAttestationsOpts) error); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitAttestations provides a mock function with given fields: ctx, attestations
+func (_m *Handler) SubmitAttestations(ctx context.Context, attestations []*phase0.Attestation) error {
+	ret := _m.Called(ctx, attestations)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitAttestations")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*phase0.Attestation) error); ok {
+		r0 = rf(ctx, attestations)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitAttestationsV2 provides a mock function with given fields: ctx, opts
+func (_m *Handler) SubmitAttestationsV2(ctx context.Context, opts *api.SubmitAttestationsOpts) error {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitAttestationsV2")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitAttestationsOpts) error); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitBlindedProposal provides a mock function with given fields: ctx, opts
+func (_m *Handler) SubmitBlindedProposal(ctx context.Context, opts *api.SubmitBlindedProposalOpts) error {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitBlindedProposal")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitBlindedProposalOpts) error); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitProposal provides a mock function with given fields: ctx, opts
+func (_m *Handler) SubmitProposal(ctx context.Context, opts *api.SubmitProposalOpts) error {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitProposal")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SubmitProposalOpts) error); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitSyncCommitteeContributions provides a mock function with given fields: ctx, contributionAndProofs
+func (_m *Handler) SubmitSyncCommitteeContributions(ctx context.Context, contributionAndProofs []*altair.SignedContributionAndProof) error {
+	ret := _m.Called(ctx, contributionAndProofs)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitSyncCommitteeContributions")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*altair.SignedContributionAndProof) error); ok {
+		r0 = rf(ctx, contributionAndProofs)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitSyncCommitteeMessages provides a mock function with given fields: ctx, messages
+func (_m *Handler) SubmitSyncCommitteeMessages(ctx context.Context, messages []*altair.SyncCommitteeMessage) error {
+	ret := _m.Called(ctx, messages)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitSyncCommitteeMessages")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*altair.SyncCommitteeMessage) error); ok {
+		r0 = rf(ctx, messages)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitValidatorRegistrations provides a mock function with given fields: ctx, registrations
+func (_m *Handler) SubmitValidatorRegistrations(ctx context.Context, registrations []*api.VersionedSignedValidatorRegistration) error {
+	ret := _m.Called(ctx, registrations)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitValidatorRegistrations")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, []*api.VersionedSignedValidatorRegistration) error); ok {
+		r0 = rf(ctx, registrations)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SubmitVoluntaryExit provides a mock function with given fields: ctx, voluntaryExit
+func (_m *Handler) SubmitVoluntaryExit(ctx context.Context, voluntaryExit *phase0.SignedVoluntaryExit) error {
+	ret := _m.Called(ctx, voluntaryExit)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SubmitVoluntaryExit")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *phase0.SignedVoluntaryExit) error); ok {
+		r0 = rf(ctx, voluntaryExit)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// SyncCommitteeContribution provides a mock function with given fields: ctx, opts
+func (_m *Handler) SyncCommitteeContribution(ctx context.Context, opts *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SyncCommitteeContribution")
+	}
+
+	var r0 *api.Response[*altair.SyncCommitteeContribution]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeContributionOpts) (*api.Response[*altair.SyncCommitteeContribution], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeContributionOpts) *api.Response[*altair.SyncCommitteeContribution]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*altair.SyncCommitteeContribution])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.SyncCommitteeContributionOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SyncCommitteeDuties provides a mock function with given fields: ctx, opts
+func (_m *Handler) SyncCommitteeDuties(ctx context.Context, opts *api.SyncCommitteeDutiesOpts) (*api.Response[[]*v1.SyncCommitteeDuty], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SyncCommitteeDuties")
+	}
+
+	var r0 *api.Response[[]*v1.SyncCommitteeDuty]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeDutiesOpts) (*api.Response[[]*v1.SyncCommitteeDuty], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeDutiesOpts) *api.Response[[]*v1.SyncCommitteeDuty]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[[]*v1.SyncCommitteeDuty])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.SyncCommitteeDutiesOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Validators provides a mock function with given fields: ctx, opts
+func (_m *Handler) Validators(ctx context.Context, opts *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*v1.Validator], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Validators")
+	}
+
+	var r0 *api.Response[map[phase0.ValidatorIndex]*v1.Validator]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*v1.Validator], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ValidatorsOpts) *api.Response[map[phase0.ValidatorIndex]*v1.Validator]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[map[phase0.ValidatorIndex]*v1.Validator])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.ValidatorsOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// NewHandler creates a new instance of Handler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewHandler(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *Handler {
+	mock := &Handler{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go
index d2263cfd..22643c0b 100644
--- a/core/validatorapi/router.go
+++ b/core/validatorapi/router.go
@@ -46,6 +46,8 @@ import (
 	"github.com/obolnetwork/charon/tbls/tblsconv"
 )
 
+//go:generate mockery --name Handler --output=mocks --outpkg=mocks --case=underscore
+
 type contentType string
 
 const (
diff --git a/docs/configuration.md b/docs/configuration.md
index 0d979726..ffc2b1f5 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -207,6 +207,8 @@ Flags:
       --testnet-genesis-timestamp int            Genesis timestamp of the custom test network.
       --testnet-name string                      Name of the custom test network.
       --validator-api-address string             Listening address (ip and port) for validator-facing traffic proxying the beacon-node API. (default "127.0.0.1:3600")
+      --vc-tls-cert-file string                  The path to the TLS certificate file used by charon for secure communication with the validator client.
+      --vc-tls-public-key-file string            The path to the TLS public key file used by the validator client for secure communication with charon.
 
 ````
 <!-- Code above generated by cmd/cmd_internal_test.go#TestConfigReference. DO NOT EDIT -->



Test patch:
diff --git a/app/app_internal_test.go b/app/app_internal_test.go
new file mode 100644
index 00000000..29e43c6a
--- /dev/null
+++ b/app/app_internal_test.go
@@ -0,0 +1,154 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package app
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"math/big"
+	"net/http"
+	"os"
+	"path"
+	"testing"
+	"time"
+
+	eth2api "github.com/attestantio/go-eth2-client/api"
+	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/eth2wrap/mocks"
+	"github.com/obolnetwork/charon/app/lifecycle"
+	vapimocks "github.com/obolnetwork/charon/core/validatorapi/mocks"
+	"github.com/obolnetwork/charon/testutil"
+)
+
+func TestWireVAPIRouterForTLS(t *testing.T) {
+	const testVersion = "v1.0.0"
+
+	life := new(lifecycle.Manager)
+	client := mocks.NewClient(t)
+	handler := vapimocks.NewHandler(t)
+	handler.On("NodeVersion", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
+		t.Log("NodeVersion called")
+	}).Return(&eth2api.Response[string]{
+		Data: testVersion,
+	}, nil)
+	vapiCalls := func() {}
+	certPath, keyPath := generateTestTLSCertAndKey(t)
+	conf := &Config{
+		VCTLSCertFile: certPath,
+		VCTLSKeyFile:  keyPath,
+	}
+
+	port := testutil.GetFreePort(t)
+	endpoint := fmt.Sprintf("localhost:%v", port)
+	err := wireVAPIRouter(t.Context(), life, endpoint, client, handler, vapiCalls, conf)
+	require.NoError(t, err)
+
+	ctx, cancel := context.WithCancel(t.Context())
+	doneCh := make(chan error)
+
+	go func() {
+		doneCh <- life.Run(ctx)
+	}()
+
+	vapiClient := createTLSClient(t, certPath, keyPath)
+	require.Eventually(t, func() bool {
+		resp, err := vapiClient.Get(fmt.Sprintf("https://%s/eth/v1/node/version", endpoint))
+		if err != nil {
+			return false
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode != http.StatusOK {
+			return false
+		}
+		b, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return false
+		}
+
+		return bytes.Contains(b, []byte(testVersion))
+	}, 5*time.Second, 100*time.Millisecond)
+
+	cancel()
+	err = <-doneCh
+	require.NoError(t, err)
+}
+
+func createTLSClient(t *testing.T, certPath, keyPath string) *http.Client {
+	t.Helper()
+
+	cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+	require.NoError(t, err)
+
+	tlsClient := &http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{
+				Certificates:       []tls.Certificate{cert},
+				InsecureSkipVerify: true,
+			},
+		},
+	}
+
+	return tlsClient
+}
+
+func generateTestTLSCertAndKey(t *testing.T) (certPath, keyPath string) {
+	t.Helper()
+
+	tempDir := t.TempDir()
+	certPath = path.Join(tempDir, "test_cert.pem")
+	keyPath = path.Join(tempDir, "test_key.pem")
+	t.Cleanup(func() {
+		os.Remove(certPath)
+		os.Remove(keyPath)
+	})
+
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject: pkix.Name{
+			Organization: []string{"ACME"},
+		},
+		NotBefore:             time.Now(),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+	}
+
+	// Generate a self-signed certificate
+	certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+	require.NoError(t, err)
+
+	certFile, err := os.Create(certPath)
+	require.NoError(t, err)
+	defer certFile.Close()
+
+	err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+	require.NoError(t, err)
+
+	keyFile, err := os.Create(keyPath)
+	require.NoError(t, err)
+	defer keyFile.Close()
+
+	privBytes, err := x509.MarshalECPrivateKey(priv)
+	require.NoError(t, err)
+
+	err = pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
+	require.NoError(t, err)
+
+	return certPath, keyPath
+}
diff --git a/app/disk_internal_test.go b/app/disk_internal_test.go
index 6dd9bf56..0f3a2ec5 100644
--- a/app/disk_internal_test.go
+++ b/app/disk_internal_test.go
@@ -4,6 +4,7 @@ package app
 
 import (
 	"context"
+	"os"
 	"testing"
 	"time"
 
@@ -94,3 +95,15 @@ func TestSetFeeRecipient(t *testing.T) {
 		require.Equal(t, active, len(clone)-(i+1))
 	}
 }
+
+func TestFileExists(t *testing.T) {
+	tempDir := t.TempDir()
+
+	tmpFile, err := os.CreateTemp(tempDir, "testfile")
+	require.NoError(t, err)
+
+	require.NoError(t, os.Remove(tmpFile.Name()))
+
+	exists := FileExists(tmpFile.Name())
+	require.False(t, exists)
+}
diff --git a/app/eth2wrap/valcache_test.go b/app/eth2wrap/valcache_test.go
index 958794e2..a7531212 100644
--- a/app/eth2wrap/valcache_test.go
+++ b/app/eth2wrap/valcache_test.go
@@ -91,7 +91,6 @@ func TestValidatorCache(t *testing.T) {
 }
 
 func TestGetBySlot(t *testing.T) {
-
 	t.Run("successful fetch", func(t *testing.T) {
 		// Create a mock client.
 		eth2Cl, err := beaconmock.New()
diff --git a/cmd/run_internal_test.go b/cmd/run_internal_test.go
index ffede2f0..88a2d9fc 100644
--- a/cmd/run_internal_test.go
+++ b/cmd/run_internal_test.go
@@ -66,3 +66,97 @@ func TestBindPrivKeyFlag(t *testing.T) {
 		})
 	}
 }
+
+func TestBindRunFlagsValidation(t *testing.T) {
+	tempDir := t.TempDir()
+	certFile, err := os.CreateTemp(tempDir, "cert.pem")
+	require.NoError(t, err)
+	keyFile, err := os.CreateTemp(tempDir, "cert.key")
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		require.NoError(t, os.Remove(certFile.Name()))
+		require.NoError(t, os.Remove(keyFile.Name()))
+	})
+
+	tests := []struct {
+		Name      string
+		Args      []string
+		AppConfig *app.Config
+		Err       string
+	}{
+		{
+			Name: "minimum flags set",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node"),
+		},
+		{
+			Name: "too long nickname",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--nickname", "thisnicknameiswaytoolongandshouldfail"),
+			Err:  "flag 'nickname' can not exceed 32 characters",
+		},
+		{
+			Name: "valid nickname",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--nickname", "validnickname"),
+		},
+		{
+			Name: "too long graffiti bytes",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--graffiti", "thisgraffitostringiswaytoolongandshouldfail"),
+			Err:  "graffiti string length is greater than maximum size",
+		},
+		{
+			Name: "valid graffiti bytes length",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--graffiti", "validgraffiti"),
+		},
+		{
+			Name: "invalid beacon node headers separator",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--beacon-node-headers", "key1=value1,key2:value2"),
+			Err:  "beacon node headers must be comma separated values formatted as header=value",
+		},
+		{
+			Name: "invalid beacon node headers completeness",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--beacon-node-headers", "key1=value1,key2="),
+			Err:  "beacon node headers must be comma separated values formatted as header=value",
+		},
+		{
+			Name: "valid beacon node headers",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--beacon-node-headers", "key1=value1,key2=value2"),
+		},
+		{
+			Name: "vc tls cert set without key",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--vc-tls-cert-file", "cert.pem"),
+			Err:  "both vc-tls-cert-file and vc-tls-public-key-file must be set or both must be empty",
+		},
+		{
+			Name: "vc tls key set without cert",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--vc-tls-public-key-file", "cert.key"),
+			Err:  "both vc-tls-cert-file and vc-tls-public-key-file must be set or both must be empty",
+		},
+		{
+			Name: "vc tls cert file does not exist",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--vc-tls-cert-file", "cert.pem", "--vc-tls-public-key-file", "cert.key"),
+			Err:  "file vc-tls-cert-file does not exist",
+		},
+		{
+			Name: "valid vc tls cert and key files",
+			Args: slice("run", "--beacon-node-endpoints", "http://beacon.node", "--vc-tls-cert-file", certFile.Name(), "--vc-tls-public-key-file", keyFile.Name()),
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.Name, func(t *testing.T) {
+			root := newRootCmd(
+				newRunCmd(func(_ context.Context, config app.Config) error {
+					return nil
+				}, false),
+			)
+
+			root.SetArgs(test.Args)
+
+			if test.Err != "" {
+				require.ErrorContains(t, root.Execute(), test.Err)
+			} else {
+				require.NoError(t, root.Execute())
+			}
+		})
+	}
+}