Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions api/v1alpha1/validator_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,20 @@ type SecretNodeKeySource struct {
SecretName string `json:"secretName"`
}

// OperatorKeyringSource declares where a validator's operator-account
// keyring (used by the sidecar to sign governance, MsgEditValidator,
// withdraw-rewards, and other operator-account transactions) comes from.
// Exactly one variant must be set; variants are mutually exclusive.
// OperatorKeyringSource configures the keyring the sidecar uses to sign
// operator-account transactions (governance, MsgEditValidator, withdraw-
// rewards, etc).
//
// +kubebuilder:validation:XValidation:rule="(has(self.secret) ? 1 : 0) == 1",message="exactly one operator keyring source must be set"
// Unset: sidecar reads a test-backend keyring at $SEI_HOME/keyring-test/
// on the data PVC — the path generate-gentx writes the validator key to
// during a genesis ceremony. Unencrypted.
//
// Set .secret: source the keyring from a passphrase-locked Secret (file
// backend). Use when the operator key is rotated externally, sourced from
// an HSM, or shared across infrastructure the controller doesn't own.
type OperatorKeyringSource struct {
// Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret
// in the SeiNode's namespace.
// Secret sources the keyring from a file-backend Secret in the SeiNode's
// namespace, projected at $SEI_HOME/keyring-file/.
// +optional
Secret *SecretOperatorKeyringSource `json:"secret,omitempty"`
}
Expand Down
8 changes: 2 additions & 6 deletions config/crd/sei.io_seinodedeployments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -685,8 +685,8 @@ spec:
properties:
secret:
description: |-
Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret
in the SeiNode's namespace.
Secret sources the keyring from a file-backend Secret in the SeiNode's
namespace, projected at $SEI_HOME/keyring-file/.
properties:
keyName:
default: node_admin
Expand Down Expand Up @@ -747,10 +747,6 @@ spec:
- secretName
type: object
type: object
x-kubernetes-validations:
- message: exactly one operator keyring source must be
set
rule: '(has(self.secret) ? 1 : 0) == 1'
signingKey:
description: |-
SigningKey declares the source of this validator's consensus signing
Expand Down
7 changes: 2 additions & 5 deletions config/crd/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ spec:
properties:
secret:
description: |-
Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret
in the SeiNode's namespace.
Secret sources the keyring from a file-backend Secret in the SeiNode's
namespace, projected at $SEI_HOME/keyring-file/.
properties:
keyName:
default: node_admin
Expand Down Expand Up @@ -618,9 +618,6 @@ spec:
- secretName
type: object
type: object
x-kubernetes-validations:
- message: exactly one operator keyring source must be set
rule: '(has(self.secret) ? 1 : 0) == 1'
signingKey:
description: |-
SigningKey declares the source of this validator's consensus signing
Expand Down
67 changes: 49 additions & 18 deletions internal/noderesource/noderesource.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,18 @@ const (
nodeKeyDataKey = "node_key.json"

operatorKeyringVolumeName = "operator-keyring"
// operatorKeyringDirName is fixed by the Cosmos SDK file-backend keyring:
// keyring.New(name, BackendFile, homeDir, ...) opens homeDir/keyring-file/.
// Not a controller choice; this constant mirrors the SDK contract.
operatorKeyringDirName = "keyring-file"
// keyring.New(BackendFile, rootDir) opens rootDir/keyring-file/.
// Used as the mount path for the projected .secret Secret.
operatorKeyringDirName = "keyring-file"

keyringPassphraseEnvVar = "SEI_KEYRING_PASSPHRASE"
// Required — the SDK's "os" compile-time default has no distroless analogue.
keyringBackendEnvVar = "SEI_KEYRING_BACKEND"
// SDK appends "keyring-<backend>"; passing $SEI_HOME resolves to
// $SEI_HOME/keyring-file/ or $SEI_HOME/keyring-test/.
keyringDirEnvVar = "SEI_KEYRING_DIR"
keyringBackendFile = "file"
keyringBackendTest = "test"

// sidecarTmpVolumeName backs an emptyDir at /tmp — required because the
// sidecar runs with ReadOnlyRootFilesystem and Go stdlib defaults to /tmp.
Expand Down Expand Up @@ -263,7 +270,9 @@ func GenerateStatefulSet(node *seiv1alpha1.SeiNode, p PlatformConfig) (*appsv1.S
// to the passphrase Secret — either alone is enough for a compromised seid
// container to recover the unlocked operator key.
//
// No-op when the node has no operator-keyring configured.
// Scoped to the .secret path: the test-backend keyring shares the data PVC
// seid already owns, so the guard has nothing to assert. Operators who need
// keyring/seid isolation set .secret, which engages this check.
func assertNoOperatorKeyringOnSeidContainers(node *seiv1alpha1.SeiNode, spec *corev1.PodSpec) error {
src := operatorKeyringSecretSource(node)
if src == nil {
Expand Down Expand Up @@ -532,6 +541,7 @@ func buildSidecarContainer(node *seiv1alpha1.SeiNode, p PlatformConfig) corev1.C
keyringMounts := operatorKeyringMounts(node)
mounts := make([]corev1.VolumeMount, 0, 2+len(keyringMounts))
mounts = append(mounts,
// Mounted RW so generate-gentx can write the operator key the sidecar later reads.
corev1.VolumeMount{Name: "data", MountPath: dataDir},
corev1.VolumeMount{Name: sidecarTmpVolumeName, MountPath: sidecarTmpMountPath},
)
Expand Down Expand Up @@ -887,25 +897,46 @@ func operatorKeyringMounts(node *seiv1alpha1.SeiNode) []corev1.VolumeMount {
}}
}

// operatorKeyringEnvVars injects the keyring unlock passphrase into the
// sidecar process via a separate Secret reference. The passphrase lives in
// its own Secret because the keyring data Secret is projected as a
// directory — co-locating the passphrase as a data key would land it as a
// file inside the keyring directory.
// operatorKeyringEnvVars configures the sidecar's keyring. Branches:
//
// - Non-validator: no env vars.
// - Validator with .secret: file backend; passphrase from the referenced
// Secret; keyring projected at $SEI_HOME/keyring-file/ by
// operatorKeyringVolumes.
// - Validator without .secret: test backend on the data PVC at
// $SEI_HOME/keyring-test/ — where generate-gentx writes the validator
// key during a genesis ceremony. Unencrypted.
//
// SEI_KEYRING_DIR is $SEI_HOME on both validator branches; the SDK appends
// "keyring-<backend>" itself.
//
// The passphrase lives in its own Secret because the keyring data Secret is
// projected as a directory — embedding the passphrase would land it inside
// that directory, and the file backend would read it as keyring contents.
func operatorKeyringEnvVars(node *seiv1alpha1.SeiNode) []corev1.EnvVar {
if node.Spec.Validator == nil {
return nil
}
src := operatorKeyringSecretSource(node)
if src == nil {
return nil
return []corev1.EnvVar{
{Name: keyringBackendEnvVar, Value: keyringBackendTest},
{Name: keyringDirEnvVar, Value: dataDir},
}
}
return []corev1.EnvVar{{
Name: keyringPassphraseEnvVar,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: src.PassphraseSecretRef.SecretName},
Key: src.PassphraseSecretRef.Key,
return []corev1.EnvVar{
{Name: keyringBackendEnvVar, Value: keyringBackendFile},
{Name: keyringDirEnvVar, Value: dataDir},
{
Name: keyringPassphraseEnvVar,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: src.PassphraseSecretRef.SecretName},
Key: src.PassphraseSecretRef.Key,
},
},
},
}}
}
}

func operatorKeyringSecretSource(node *seiv1alpha1.SeiNode) *seiv1alpha1.SecretOperatorKeyringSource {
Expand Down
49 changes: 49 additions & 0 deletions internal/noderesource/noderesource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,11 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) {
g.Expect(mount.SubPath).To(BeEmpty(),
"operator-keyring is a directory mount, not subPath — sidecar needs the whole dir")

g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(Equal(keyringBackendFile),
"SEI_KEYRING_BACKEND=file required; SDK 'os' default has no distroless analogue")
g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir),
"SEI_KEYRING_DIR=$SEI_HOME; SDK appends keyring-file/ (doubled path otherwise)")

var passphraseEnv *corev1.EnvVar
for i := range sidecar.Env {
if sidecar.Env[i].Name == keyringPassphraseEnvVar {
Expand All @@ -1027,6 +1032,50 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) {
g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Key).To(Equal("passphrase"))
}

// Validator without .secret defaults to a test-backend keyring rooted at
// $SEI_HOME — SDK resolves to $SEI_HOME/keyring-test/, where generate-gentx
// writes the validator key.
func TestOperatorKeyring_ValidatorWithoutSecret_TestBackend(t *testing.T) {
g := NewWithT(t)
node := newValidatorNodeWithSigningKey("validator-0", "default", "validator-0-key")

sts := mustGenerateStatefulSet(t, node, platformtest.Config())
sidecar := findInitContainer(sts.Spec.Template.Spec.InitContainers, "sei-sidecar")
g.Expect(sidecar).NotTo(BeNil(), "sei-sidecar init container must exist")

g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(Equal(keyringBackendTest),
"default test backend reads the gentx-written keyring on the data PVC")
g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir),
"SEI_KEYRING_DIR=$SEI_HOME; SDK appends keyring-test/ (doubled path otherwise)")
g.Expect(envValue(sidecar.Env, keyringPassphraseEnvVar)).To(BeEmpty(),
"test backend is unencrypted; no passphrase env")

g.Expect(findVolume(sts.Spec.Template.Spec.Volumes, operatorKeyringVolumeName)).To(BeNil(),
"no .secret means no projected Secret volume — the keyring lives on the data PVC")

dataMount := findVolumeMount(sidecar.VolumeMounts, "data")
g.Expect(dataMount).NotTo(BeNil(),
"sidecar mounts the data PVC: generate-gentx writes the operator key, sign-and-broadcast reads it")
g.Expect(dataMount.MountPath).To(Equal(dataDir))
g.Expect(dataMount.ReadOnly).To(BeFalse(),
"data mount must be RW for generate-gentx to write $SEI_HOME/keyring-test/")
}

// Non-validator SeiNodes get no keyring env vars — nothing to sign for.
func TestOperatorKeyring_NonValidator_NoEnv(t *testing.T) {
g := NewWithT(t)
node := newSnapshotNode("snap-0", "default")

sts := mustGenerateStatefulSet(t, node, platformtest.Config())
sidecar := findInitContainer(sts.Spec.Template.Spec.InitContainers, "sei-sidecar")
g.Expect(sidecar).NotTo(BeNil())

g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(BeEmpty(),
"non-validator SeiNodes must not have SEI_KEYRING_BACKEND set")
g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(BeEmpty())
g.Expect(envValue(sidecar.Env, keyringPassphraseEnvVar)).To(BeEmpty())
}

func TestOperatorKeyring_SeidMainContainerHasNoMountOrEnv(t *testing.T) {
g := NewWithT(t)
node := newValidatorNodeWithOperatorKeyring("validator-0", "default")
Expand Down
8 changes: 2 additions & 6 deletions manifests/sei.io_seinodedeployments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -685,8 +685,8 @@ spec:
properties:
secret:
description: |-
Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret
in the SeiNode's namespace.
Secret sources the keyring from a file-backend Secret in the SeiNode's
namespace, projected at $SEI_HOME/keyring-file/.
properties:
keyName:
default: node_admin
Expand Down Expand Up @@ -747,10 +747,6 @@ spec:
- secretName
type: object
type: object
x-kubernetes-validations:
- message: exactly one operator keyring source must be
set
rule: '(has(self.secret) ? 1 : 0) == 1'
signingKey:
description: |-
SigningKey declares the source of this validator's consensus signing
Expand Down
7 changes: 2 additions & 5 deletions manifests/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -556,8 +556,8 @@ spec:
properties:
secret:
description: |-
Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret
in the SeiNode's namespace.
Secret sources the keyring from a file-backend Secret in the SeiNode's
namespace, projected at $SEI_HOME/keyring-file/.
properties:
keyName:
default: node_admin
Expand Down Expand Up @@ -618,9 +618,6 @@ spec:
- secretName
type: object
type: object
x-kubernetes-validations:
- message: exactly one operator keyring source must be set
rule: '(has(self.secret) ? 1 : 0) == 1'
signingKey:
description: |-
SigningKey declares the source of this validator's consensus signing
Expand Down
Loading