diff --git a/api/v1alpha1/validator_types.go b/api/v1alpha1/validator_types.go index 355fc1c..ab483c6 100644 --- a/api/v1alpha1/validator_types.go +++ b/api/v1alpha1/validator_types.go @@ -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"` } diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 694051a..7923eb8 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -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 @@ -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 diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index cca2334..f6b125e 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -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 @@ -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 diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index 469c354..89001fb 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -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-"; 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. @@ -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 { @@ -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}, ) @@ -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-" 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 { diff --git a/internal/noderesource/noderesource_test.go b/internal/noderesource/noderesource_test.go index ebaa8a5..61114b6 100644 --- a/internal/noderesource/noderesource_test.go +++ b/internal/noderesource/noderesource_test.go @@ -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 { @@ -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") diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 694051a..7923eb8 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -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 @@ -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 diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index cca2334..f6b125e 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -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 @@ -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