From d8f038378769b9faa0e975f144ed3be5aaa00727 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 19 May 2026 18:56:57 -0700 Subject: [PATCH 1/5] fix(noderesource): wire SEI_KEYRING_BACKEND; add gentx-key fallback path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing .secret operator-keyring path emitted only SEI_KEYRING_PASSPHRASE, leaving SEI_KEYRING_BACKEND unset — the Cosmos SDK then fell back to its compile-time default ("os") which has no distroless analogue, so the sidecar could not open the projected file-backend keyring. Set SEI_KEYRING_BACKEND=file explicitly on the .secret path. Add an empty-block opt-in to OperatorKeyringSource: when an operator sets operatorKeyring: {} without .secret, the controller wires the sidecar with SEI_KEYRING_BACKEND=test and SEI_KEYRING_DIR=$SEI_HOME/keyring-test — where the seictl gentx task writes the validator key during the genesis ceremony. Reusing the gentx key avoids requiring a per-deployment operator Secret on ephemeral / bench chains where the data PVC is already the trust boundary. Production chains continue to set .secret. The empty-block path on a prod chain means sign-tx tasks fail at execution with "key not found" rather than silently falling back to an unintended identity. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/v1alpha1/validator_types.go | 19 +++++- config/crd/sei.io_seinodedeployments.yaml | 7 +- config/crd/sei.io_seinodes.yaml | 6 +- internal/noderesource/noderesource.go | 75 ++++++++++++++++++---- internal/noderesource/noderesource_test.go | 47 ++++++++++++++ manifests/sei.io_seinodedeployments.yaml | 7 +- manifests/sei.io_seinodes.yaml | 6 +- 7 files changed, 132 insertions(+), 35 deletions(-) diff --git a/api/v1alpha1/validator_types.go b/api/v1alpha1/validator_types.go index 355fc1c..52630c1 100644 --- a/api/v1alpha1/validator_types.go +++ b/api/v1alpha1/validator_types.go @@ -138,12 +138,25 @@ type SecretNodeKeySource struct { // 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. +// Setting this field is the validator's opt-in to sidecar signing. // -// +kubebuilder:validation:XValidation:rule="(has(self.secret) ? 1 : 0) == 1",message="exactly one operator keyring source must be set" +// Two shapes, distinguished by whether .secret is set: +// +// - .secret set: load a Cosmos SDK file-backend keyring from the named +// Secret (production). Sidecar unlocks it with the referenced passphrase. +// - .secret unset (empty block): reuse the keyring written by the +// genesis-ceremony gentx task at $SEI_HOME/keyring-test/ on the data +// PVC (Cosmos SDK test backend, unencrypted). Intended for bench / +// ephemeral chains where the PVC is the natural trust boundary. +// +// Production chains should always set .secret. An empty block on a prod +// chain means sign-tx tasks fail at execution with "key not found" — the +// sidecar opens an empty keyring rather than silently falling back to an +// unintended identity. type OperatorKeyringSource struct { // Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - // in the SeiNode's namespace. + // in the SeiNode's namespace. When unset, the sidecar reads the keyring + // written by the gentx task on the shared data PVC instead. // +optional Secret *SecretOperatorKeyringSource `json:"secret,omitempty"` } diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 694051a..8ade88b 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -686,7 +686,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. + in the SeiNode's namespace. When unset, the sidecar reads the keyring + written by the gentx task on the shared data PVC instead. properties: keyName: default: node_admin @@ -747,10 +748,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..b4947cb 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -557,7 +557,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. + in the SeiNode's namespace. When unset, the sidecar reads the keyring + written by the gentx task on the shared data PVC instead. properties: keyName: default: node_admin @@ -618,9 +619,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..bd85198 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -77,8 +77,28 @@ const ( // 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" + operatorKeyringDirName = "keyring-file" + // operatorKeyringTestDirName is the corresponding test-backend + // subdirectory. The gentx task (in seictl) writes the validator key here + // during the genesis ceremony; the sidecar reads it back from the same + // path on the shared data PVC when no .secret Secret is provided. + operatorKeyringTestDirName = "keyring-test" + keyringPassphraseEnvVar = "SEI_KEYRING_PASSPHRASE" + // keyringBackendEnvVar selects the Cosmos SDK keyring backend the + // sidecar uses for operator-account signing. Must be set explicitly — + // the SDK's compile-time default ("os" on workstations) has no + // distroless analogue and would prevent the sidecar from opening any + // keyring at all. + keyringBackendEnvVar = "SEI_KEYRING_BACKEND" + // keyringDirEnvVar overrides the keyring root directory. Only emitted + // on the test-backend / gentx-fallback path so the sidecar opens the + // gentx-written keyring on the data PVC. The file-backend path leaves + // the SDK default ($SEI_HOME/keyring-file/) intact — operatorKeyringVolumes + // mounts the projected Secret directory there. + 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. @@ -887,25 +907,52 @@ 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 backend for +// operator-account signing. Three branches keyed on what the validator's +// OperatorKeyring spec carries: +// +// - OperatorKeyring unset: sidecar has no signing intent — no env vars. +// - OperatorKeyring set with .secret: file backend. SEI_KEYRING_BACKEND=file +// plus SEI_KEYRING_PASSPHRASE sourced from the referenced passphrase +// Secret. The keyring data Secret is projected as a directory at +// $SEI_HOME/keyring-file/ (the SDK's file-backend default) by +// operatorKeyringVolumes. +// - OperatorKeyring set with .secret unset (empty block): test backend +// pointing at $SEI_HOME/keyring-test/ on the shared data PVC. This is +// where the seictl gentx task writes the validator key during the +// genesis ceremony; reusing it avoids requiring a separate operator- +// account Secret for ephemeral / bench chains. No passphrase — the +// test backend is unencrypted. Production chains should always set +// .secret; an empty block on prod means sign-tx tasks fail at execution +// with "key not found" rather than silently falling back. +// +// The passphrase Secret is separate from the keyring data 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 and the file-backend would treat it as keyring contents. func operatorKeyringEnvVars(node *seiv1alpha1.SeiNode) []corev1.EnvVar { + if node.Spec.Validator == nil || node.Spec.Validator.OperatorKeyring == nil { + return nil + } src := operatorKeyringSecretSource(node) if src == nil { - return nil + return []corev1.EnvVar{ + {Name: keyringBackendEnvVar, Value: keyringBackendTest}, + {Name: keyringDirEnvVar, Value: dataDir + "/" + operatorKeyringTestDirName}, + } } - 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: 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..cbe8184 100644 --- a/internal/noderesource/noderesource_test.go +++ b/internal/noderesource/noderesource_test.go @@ -1014,6 +1014,9 @@ 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), + "sidecar must have SEI_KEYRING_BACKEND=file so the SDK opens the projected Secret as a file-backend keyring; without this the SDK falls back to its compile-time default which has no distroless analogue") + var passphraseEnv *corev1.EnvVar for i := range sidecar.Env { if sidecar.Env[i].Name == keyringPassphraseEnvVar { @@ -1025,6 +1028,50 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) { g.Expect(passphraseEnv.ValueFrom.SecretKeyRef).NotTo(BeNil()) g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Name).To(Equal("validator-0-opk-passphrase")) g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Key).To(Equal("passphrase")) + + g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(BeEmpty(), + "file-backend path uses the SDK default keyring dir ($SEI_HOME/keyring-file/); SEI_KEYRING_DIR must NOT be set or the SDK skips the projected Secret volume") +} + +// TestOperatorKeyring_EmptyBlock_TestBackend covers the gentx-fallback path: +// validators that opt into sidecar signing but provide no Secret get a +// test-backend keyring pointing at $SEI_HOME/keyring-test on the data PVC, +// which is where the seictl gentx task writes the validator key. +func TestOperatorKeyring_EmptyBlock_TestBackend(t *testing.T) { + g := NewWithT(t) + node := newValidatorNodeWithSigningKey("validator-0", "default", "validator-0-key") + node.Spec.Validator.OperatorKeyring = &seiv1alpha1.OperatorKeyringSource{} + + 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), + "empty operatorKeyring block must select the test backend so the sidecar reads the gentx-written keyring") + g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir+"/"+operatorKeyringTestDirName), + "SEI_KEYRING_DIR must point at the gentx-written keyring on the data PVC") + g.Expect(envValue(sidecar.Env, keyringPassphraseEnvVar)).To(BeEmpty(), + "test backend is unencrypted; no passphrase env should be set") + + g.Expect(findVolume(sts.Spec.Template.Spec.Volumes, operatorKeyringVolumeName)).To(BeNil(), + "empty operatorKeyring block must NOT mount a projected Secret — the keyring lives on the data PVC already") +} + +// TestOperatorKeyring_ValidatorWithoutOptIn_NoEnv guards the explicit opt-in +// semantic: a validator without an OperatorKeyring block performs no sidecar +// signing and must not receive any keyring env vars. +func TestOperatorKeyring_ValidatorWithoutOptIn_NoEnv(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()) + + g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(BeEmpty(), + "validators without operatorKeyring opt-in 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) { diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 694051a..8ade88b 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -686,7 +686,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. + in the SeiNode's namespace. When unset, the sidecar reads the keyring + written by the gentx task on the shared data PVC instead. properties: keyName: default: node_admin @@ -747,10 +748,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..b4947cb 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -557,7 +557,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. + in the SeiNode's namespace. When unset, the sidecar reads the keyring + written by the gentx task on the shared data PVC instead. properties: keyName: default: node_admin @@ -618,9 +619,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 From 8c2eee33254d70208ad56bb0daaa7a747f1e29fa Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 19 May 2026 19:14:16 -0700 Subject: [PATCH 2/5] refactor(noderesource): default validators to test-backend keyring; fix SEI_KEYRING_DIR path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validators inherently want signing capability — the empty-block opt-in (operatorKeyring: {}) was carrying zero information. Drop it: any SeiNode with a Validator spec now gets keyring env vars by default, and .secret becomes the override for the passphrase-locked / projected-Secret path (symmetric with signingKey and nodeKey). Non-validators continue to get no keyring env vars. Fix the SEI_KEYRING_DIR value on both branches. The Cosmos SDK appends "keyring-" to whatever rootDir we pass — so the prior SEI_KEYRING_DIR=\$SEI_HOME/keyring-test made the SDK resolve \$SEI_HOME/keyring-test/keyring-test/, where the gentx task never writes. Pass \$SEI_HOME on both branches and let the SDK append the suffix; this also makes the resolution behavior symmetric between file and test backends. Update OperatorKeyringSource docstring to describe the override semantic (default test-backend, .secret overrides to file-backend). Drop project- specific framing — this is general-purpose infrastructure. Tests renamed and rescoped: validator-without-secret asserts the default test-backend wiring; non-validator asserts no env vars. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/v1alpha1/validator_types.go | 28 ++++----- config/crd/sei.io_seinodedeployments.yaml | 4 +- config/crd/sei.io_seinodes.yaml | 4 +- internal/noderesource/noderesource.go | 72 ++++++++++++---------- internal/noderesource/noderesource_test.go | 36 +++++------ manifests/sei.io_seinodedeployments.yaml | 4 +- manifests/sei.io_seinodes.yaml | 4 +- 7 files changed, 76 insertions(+), 76 deletions(-) diff --git a/api/v1alpha1/validator_types.go b/api/v1alpha1/validator_types.go index 52630c1..3446ac6 100644 --- a/api/v1alpha1/validator_types.go +++ b/api/v1alpha1/validator_types.go @@ -135,28 +135,24 @@ type SecretNodeKeySource struct { SecretName string `json:"secretName"` } -// OperatorKeyringSource declares where a validator's operator-account +// OperatorKeyringSource overrides where a validator's operator-account // keyring (used by the sidecar to sign governance, MsgEditValidator, // withdraw-rewards, and other operator-account transactions) comes from. -// Setting this field is the validator's opt-in to sidecar signing. // -// Two shapes, distinguished by whether .secret is set: +// Validators sign by default: when this field is omitted, the controller +// wires the sidecar's keyring to the data PVC's test-backend directory +// ($SEI_HOME/keyring-test/) — the same path the generate-gentx task writes +// the validator key to when the SeiNode is provisioned via a genesis +// ceremony. No passphrase; the test backend is unencrypted. // -// - .secret set: load a Cosmos SDK file-backend keyring from the named -// Secret (production). Sidecar unlocks it with the referenced passphrase. -// - .secret unset (empty block): reuse the keyring written by the -// genesis-ceremony gentx task at $SEI_HOME/keyring-test/ on the data -// PVC (Cosmos SDK test backend, unencrypted). Intended for bench / -// ephemeral chains where the PVC is the natural trust boundary. -// -// Production chains should always set .secret. An empty block on a prod -// chain means sign-tx tasks fail at execution with "key not found" — the -// sidecar opens an empty keyring rather than silently falling back to an -// unintended identity. +// Set .secret to override with a passphrase-locked, projected-Secret-backed +// keyring (file backend). Use this when the operator key is rotated +// externally, sourced from an HSM-export, or shared across infrastructure +// the SeiNode controller doesn't own. type OperatorKeyringSource struct { // Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - // in the SeiNode's namespace. When unset, the sidecar reads the keyring - // written by the gentx task on the shared data PVC instead. + // in the SeiNode's namespace. Overrides the default test-backend keyring + // on the data PVC. // +optional Secret *SecretOperatorKeyringSource `json:"secret,omitempty"` } diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 8ade88b..baabe26 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -686,8 +686,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. When unset, the sidecar reads the keyring - written by the gentx task on the shared data PVC instead. + in the SeiNode's namespace. Overrides the default test-backend keyring + on the data PVC. properties: keyName: default: node_admin diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index b4947cb..fe661f7 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -557,8 +557,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. When unset, the sidecar reads the keyring - written by the gentx task on the shared data PVC instead. + in the SeiNode's namespace. Overrides the default test-backend keyring + on the data PVC. properties: keyName: default: node_admin diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index bd85198..e201a14 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -74,15 +74,10 @@ 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 mirrors the Cosmos SDK file-backend subdirectory: + // keyring.New(name, BackendFile, rootDir, ...) opens rootDir/keyring-file/. + // Used as the mount path for the projected Secret on the .secret path. operatorKeyringDirName = "keyring-file" - // operatorKeyringTestDirName is the corresponding test-backend - // subdirectory. The gentx task (in seictl) writes the validator key here - // during the genesis ceremony; the sidecar reads it back from the same - // path on the shared data PVC when no .secret Secret is provided. - operatorKeyringTestDirName = "keyring-test" keyringPassphraseEnvVar = "SEI_KEYRING_PASSPHRASE" // keyringBackendEnvVar selects the Cosmos SDK keyring backend the @@ -91,11 +86,12 @@ const ( // distroless analogue and would prevent the sidecar from opening any // keyring at all. keyringBackendEnvVar = "SEI_KEYRING_BACKEND" - // keyringDirEnvVar overrides the keyring root directory. Only emitted - // on the test-backend / gentx-fallback path so the sidecar opens the - // gentx-written keyring on the data PVC. The file-backend path leaves - // the SDK default ($SEI_HOME/keyring-file/) intact — operatorKeyringVolumes - // mounts the projected Secret directory there. + // keyringDirEnvVar carries the keyring root directory. The SDK appends + // "keyring-" to whatever this points at — so passing $SEI_HOME + // makes the file backend resolve to $SEI_HOME/keyring-file/ and the test + // backend to $SEI_HOME/keyring-test/. Always emitted to make the path + // resolution explicit rather than relying on the sidecar's compile-time + // default. keyringDirEnvVar = "SEI_KEYRING_DIR" keyringBackendFile = "file" keyringBackendTest = "test" @@ -283,7 +279,13 @@ 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 default test-backend keyring lives at +// $SEI_HOME/keyring-test/ on the shared data PVC, which seid necessarily +// mounts to read its config and write chain state — there is no separate +// volume or passphrase to leak. The trust model on that path explicitly +// concedes the PVC to seid, so this guard has nothing to assert and returns +// nil. Operators who need the keyring isolated from seid set .secret, which +// engages this guard. func assertNoOperatorKeyringOnSeidContainers(node *seiv1alpha1.SeiNode, spec *corev1.PodSpec) error { src := operatorKeyringSecretSource(node) if src == nil { @@ -908,41 +910,45 @@ func operatorKeyringMounts(node *seiv1alpha1.SeiNode) []corev1.VolumeMount { } // operatorKeyringEnvVars configures the sidecar's keyring backend for -// operator-account signing. Three branches keyed on what the validator's -// OperatorKeyring spec carries: +// operator-account signing. Two branches, keyed on whether the validator +// declares a Secret-backed keyring source: // -// - OperatorKeyring unset: sidecar has no signing intent — no env vars. -// - OperatorKeyring set with .secret: file backend. SEI_KEYRING_BACKEND=file -// plus SEI_KEYRING_PASSPHRASE sourced from the referenced passphrase -// Secret. The keyring data Secret is projected as a directory at -// $SEI_HOME/keyring-file/ (the SDK's file-backend default) by -// operatorKeyringVolumes. -// - OperatorKeyring set with .secret unset (empty block): test backend -// pointing at $SEI_HOME/keyring-test/ on the shared data PVC. This is -// where the seictl gentx task writes the validator key during the -// genesis ceremony; reusing it avoids requiring a separate operator- -// account Secret for ephemeral / bench chains. No passphrase — the -// test backend is unencrypted. Production chains should always set -// .secret; an empty block on prod means sign-tx tasks fail at execution -// with "key not found" rather than silently falling back. +// - Non-validator: no env vars (sidecar has nothing to sign for). +// - Validator with .secret: file backend. SEI_KEYRING_BACKEND=file plus +// SEI_KEYRING_PASSPHRASE sourced from the referenced passphrase Secret. +// operatorKeyringVolumes projects the keyring data Secret as a directory +// at $SEI_HOME/keyring-file/; the SDK's file-backend resolves there. +// - Validator without .secret: test backend pointing at $SEI_HOME. The SDK +// appends "keyring-test" so the sidecar reads $SEI_HOME/keyring-test/ on +// the shared data PVC — the same path the generate-gentx task writes the +// validator key to during the genesis ceremony. No passphrase; the test +// backend is unencrypted. Operators who want passphrase-locked, projected- +// Secret semantics on the same node set .secret instead. +// +// Both branches emit SEI_KEYRING_DIR explicitly. The SDK appends the +// "keyring-" suffix itself, so we pass the root ($SEI_HOME) and let +// the SDK resolve. This avoids embedding SDK directory-layout knowledge in +// the controller and keeps the resolution behavior symmetric between the +// two backends. // // The passphrase Secret is separate from the keyring data 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 and the file-backend would treat it as keyring contents. +// directory and the file backend would treat it as keyring contents. func operatorKeyringEnvVars(node *seiv1alpha1.SeiNode) []corev1.EnvVar { - if node.Spec.Validator == nil || node.Spec.Validator.OperatorKeyring == nil { + if node.Spec.Validator == nil { return nil } src := operatorKeyringSecretSource(node) if src == nil { return []corev1.EnvVar{ {Name: keyringBackendEnvVar, Value: keyringBackendTest}, - {Name: keyringDirEnvVar, Value: dataDir + "/" + operatorKeyringTestDirName}, + {Name: keyringDirEnvVar, Value: dataDir}, } } return []corev1.EnvVar{ {Name: keyringBackendEnvVar, Value: keyringBackendFile}, + {Name: keyringDirEnvVar, Value: dataDir}, { Name: keyringPassphraseEnvVar, ValueFrom: &corev1.EnvVarSource{ diff --git a/internal/noderesource/noderesource_test.go b/internal/noderesource/noderesource_test.go index cbe8184..915f233 100644 --- a/internal/noderesource/noderesource_test.go +++ b/internal/noderesource/noderesource_test.go @@ -1016,6 +1016,8 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) { g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(Equal(keyringBackendFile), "sidecar must have SEI_KEYRING_BACKEND=file so the SDK opens the projected Secret as a file-backend keyring; without this the SDK falls back to its compile-time default which has no distroless analogue") + g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir), + "SEI_KEYRING_DIR must point at $SEI_HOME — the SDK appends keyring-file/ itself; passing a deeper path makes the SDK look at $SEI_HOME/keyring-file/keyring-file/") var passphraseEnv *corev1.EnvVar for i := range sidecar.Env { @@ -1028,48 +1030,44 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) { g.Expect(passphraseEnv.ValueFrom.SecretKeyRef).NotTo(BeNil()) g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Name).To(Equal("validator-0-opk-passphrase")) g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Key).To(Equal("passphrase")) - - g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(BeEmpty(), - "file-backend path uses the SDK default keyring dir ($SEI_HOME/keyring-file/); SEI_KEYRING_DIR must NOT be set or the SDK skips the projected Secret volume") } -// TestOperatorKeyring_EmptyBlock_TestBackend covers the gentx-fallback path: -// validators that opt into sidecar signing but provide no Secret get a -// test-backend keyring pointing at $SEI_HOME/keyring-test on the data PVC, -// which is where the seictl gentx task writes the validator key. -func TestOperatorKeyring_EmptyBlock_TestBackend(t *testing.T) { +// TestOperatorKeyring_ValidatorWithoutSecret_TestBackend covers the implicit +// default: a validator without .secret gets a test-backend keyring rooted at +// $SEI_HOME so the SDK resolves to $SEI_HOME/keyring-test/, where the +// generate-gentx task writes the validator key during the genesis ceremony. +func TestOperatorKeyring_ValidatorWithoutSecret_TestBackend(t *testing.T) { g := NewWithT(t) node := newValidatorNodeWithSigningKey("validator-0", "default", "validator-0-key") - node.Spec.Validator.OperatorKeyring = &seiv1alpha1.OperatorKeyringSource{} 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), - "empty operatorKeyring block must select the test backend so the sidecar reads the gentx-written keyring") - g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir+"/"+operatorKeyringTestDirName), - "SEI_KEYRING_DIR must point at the gentx-written keyring on the data PVC") + "validators without .secret default to the test backend so the sidecar reads the gentx-written keyring on the data PVC") + g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir), + "SEI_KEYRING_DIR must be $SEI_HOME — the SDK appends keyring-test/ itself; passing $SEI_HOME/keyring-test makes the SDK resolve $SEI_HOME/keyring-test/keyring-test/") g.Expect(envValue(sidecar.Env, keyringPassphraseEnvVar)).To(BeEmpty(), "test backend is unencrypted; no passphrase env should be set") g.Expect(findVolume(sts.Spec.Template.Spec.Volumes, operatorKeyringVolumeName)).To(BeNil(), - "empty operatorKeyring block must NOT mount a projected Secret — the keyring lives on the data PVC already") + "no .secret means no projected Secret volume — the keyring lives on the data PVC") } -// TestOperatorKeyring_ValidatorWithoutOptIn_NoEnv guards the explicit opt-in -// semantic: a validator without an OperatorKeyring block performs no sidecar -// signing and must not receive any keyring env vars. -func TestOperatorKeyring_ValidatorWithoutOptIn_NoEnv(t *testing.T) { +// TestOperatorKeyring_NonValidator_NoEnv guards the non-validator branch: +// SeiNodes without a Validator spec (full nodes, snapshots) must not receive +// keyring env vars — they have nothing to sign for. +func TestOperatorKeyring_NonValidator_NoEnv(t *testing.T) { g := NewWithT(t) - node := newValidatorNodeWithSigningKey("validator-0", "default", "validator-0-key") + 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(), - "validators without operatorKeyring opt-in must not have SEI_KEYRING_BACKEND set") + "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()) } diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 8ade88b..baabe26 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -686,8 +686,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. When unset, the sidecar reads the keyring - written by the gentx task on the shared data PVC instead. + in the SeiNode's namespace. Overrides the default test-backend keyring + on the data PVC. properties: keyName: default: node_admin diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index b4947cb..fe661f7 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -557,8 +557,8 @@ spec: secret: description: |- Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. When unset, the sidecar reads the keyring - written by the gentx task on the shared data PVC instead. + in the SeiNode's namespace. Overrides the default test-backend keyring + on the data PVC. properties: keyName: default: node_admin From 1b86beb2992d567be8273089bd8bd28c90c6e1a8 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 19 May 2026 19:20:46 -0700 Subject: [PATCH 3/5] docs: declarative present-tense pass on operator-keyring comments Reframe OperatorKeyringSource docstring around current behavior: "with this field unset, the controller wires X" rather than "validators sign by default; set .secret to override." Same semantics, less change-framing. Fix the off-by-one in operatorKeyringEnvVars's doc ("Two branches" preceded a three-bullet list) and tighten the surrounding prose. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/v1alpha1/validator_types.go | 32 ++++++++++++----------- config/crd/sei.io_seinodedeployments.yaml | 6 ++--- config/crd/sei.io_seinodes.yaml | 6 ++--- internal/noderesource/noderesource.go | 30 ++++++++++----------- manifests/sei.io_seinodedeployments.yaml | 6 ++--- manifests/sei.io_seinodes.yaml | 6 ++--- 6 files changed, 44 insertions(+), 42 deletions(-) diff --git a/api/v1alpha1/validator_types.go b/api/v1alpha1/validator_types.go index 3446ac6..b8c9a5a 100644 --- a/api/v1alpha1/validator_types.go +++ b/api/v1alpha1/validator_types.go @@ -135,24 +135,26 @@ type SecretNodeKeySource struct { SecretName string `json:"secretName"` } -// OperatorKeyringSource overrides where a validator's operator-account -// keyring (used by the sidecar to sign governance, MsgEditValidator, -// withdraw-rewards, and other operator-account transactions) comes from. +// OperatorKeyringSource configures the source of a validator's operator- +// account keyring — the keyring the sidecar uses to sign governance, +// MsgEditValidator, withdraw-rewards, and other operator-account +// transactions. // -// Validators sign by default: when this field is omitted, the controller -// wires the sidecar's keyring to the data PVC's test-backend directory -// ($SEI_HOME/keyring-test/) — the same path the generate-gentx task writes -// the validator key to when the SeiNode is provisioned via a genesis -// ceremony. No passphrase; the test backend is unencrypted. +// With this field unset, the controller wires the sidecar to a test- +// backend keyring at $SEI_HOME/keyring-test/ on the shared data PVC. This +// is the same path the generate-gentx task writes the validator key to +// during a genesis ceremony, so genesis-provisioned validators sign +// without additional configuration. The test backend is unencrypted. // -// Set .secret to override with a passphrase-locked, projected-Secret-backed -// keyring (file backend). Use this when the operator key is rotated -// externally, sourced from an HSM-export, or shared across infrastructure -// the SeiNode controller doesn't own. +// Set .secret to source the keyring from a passphrase-locked Secret +// projected into the sidecar (file backend). This is the path for +// operators whose operator key is rotated externally, sourced from an +// HSM-export, or shared across infrastructure the SeiNode controller +// doesn't own. type OperatorKeyringSource struct { - // Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - // in the SeiNode's namespace. Overrides the default test-backend keyring - // on the data PVC. + // Secret sources the keyring from a Cosmos SDK file-backend Kubernetes + // Secret in the SeiNode's namespace, projected into the sidecar 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 baabe26..5434712 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -685,9 +685,9 @@ spec: properties: secret: description: |- - Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. Overrides the default test-backend keyring - on the data PVC. + Secret sources the keyring from a Cosmos SDK file-backend Kubernetes + Secret in the SeiNode's namespace, projected into the sidecar at + $SEI_HOME/keyring-file/. properties: keyName: default: node_admin diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index fe661f7..2f50b84 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -556,9 +556,9 @@ spec: properties: secret: description: |- - Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. Overrides the default test-backend keyring - on the data PVC. + Secret sources the keyring from a Cosmos SDK file-backend Kubernetes + Secret in the SeiNode's namespace, projected into the sidecar at + $SEI_HOME/keyring-file/. properties: keyName: default: node_admin diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index e201a14..db018d8 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -910,26 +910,26 @@ func operatorKeyringMounts(node *seiv1alpha1.SeiNode) []corev1.VolumeMount { } // operatorKeyringEnvVars configures the sidecar's keyring backend for -// operator-account signing. Two branches, keyed on whether the validator -// declares a Secret-backed keyring source: +// operator-account signing. The branch fires on validator role and whether +// the validator declares a Secret-backed keyring source: // -// - Non-validator: no env vars (sidecar has nothing to sign for). +// - Non-validator: no env vars. The sidecar has nothing to sign for. // - Validator with .secret: file backend. SEI_KEYRING_BACKEND=file plus // SEI_KEYRING_PASSPHRASE sourced from the referenced passphrase Secret. // operatorKeyringVolumes projects the keyring data Secret as a directory -// at $SEI_HOME/keyring-file/; the SDK's file-backend resolves there. -// - Validator without .secret: test backend pointing at $SEI_HOME. The SDK -// appends "keyring-test" so the sidecar reads $SEI_HOME/keyring-test/ on -// the shared data PVC — the same path the generate-gentx task writes the -// validator key to during the genesis ceremony. No passphrase; the test -// backend is unencrypted. Operators who want passphrase-locked, projected- -// Secret semantics on the same node set .secret instead. +// at $SEI_HOME/keyring-file/; the SDK's file backend resolves there. +// - Validator without .secret: test backend rooted at $SEI_HOME. The SDK +// appends "keyring-test" and the sidecar reads $SEI_HOME/keyring-test/ +// on the shared data PVC — the same path the generate-gentx task writes +// the validator key to during a genesis ceremony. No passphrase; the +// test backend is unencrypted. Operators who want passphrase-locked, +// projected-Secret semantics on the same node set .secret. // -// Both branches emit SEI_KEYRING_DIR explicitly. The SDK appends the -// "keyring-" suffix itself, so we pass the root ($SEI_HOME) and let -// the SDK resolve. This avoids embedding SDK directory-layout knowledge in -// the controller and keeps the resolution behavior symmetric between the -// two backends. +// Both validator branches emit SEI_KEYRING_DIR explicitly. The SDK appends +// the "keyring-" suffix itself, so the controller passes the root +// ($SEI_HOME) and lets the SDK resolve. This keeps SDK directory-layout +// knowledge out of the controller and the resolution behavior symmetric +// between the two backends. // // The passphrase Secret is separate from the keyring data Secret because // the keyring data Secret is projected as a directory — co-locating the diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index baabe26..5434712 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -685,9 +685,9 @@ spec: properties: secret: description: |- - Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. Overrides the default test-backend keyring - on the data PVC. + Secret sources the keyring from a Cosmos SDK file-backend Kubernetes + Secret in the SeiNode's namespace, projected into the sidecar at + $SEI_HOME/keyring-file/. properties: keyName: default: node_admin diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index fe661f7..2f50b84 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -556,9 +556,9 @@ spec: properties: secret: description: |- - Secret loads a Cosmos SDK file-backend keyring from a Kubernetes Secret - in the SeiNode's namespace. Overrides the default test-backend keyring - on the data PVC. + Secret sources the keyring from a Cosmos SDK file-backend Kubernetes + Secret in the SeiNode's namespace, projected into the sidecar at + $SEI_HOME/keyring-file/. properties: keyName: default: node_admin From 0db07403f980ba09a69c95066091f0fce1f9c3c4 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 19 May 2026 19:27:08 -0700 Subject: [PATCH 4/5] test: assert sidecar data PVC is RW-mounted; comment the invariant The default test-backend signing path rides on the sidecar both writing the operator key to and reading it from the data PVC. Make that invariant load-bearing in tests (assert the data mount exists, is at dataDir, and is not ReadOnly) and surface it on the mount declaration itself so a future "harden the sidecar" change doesn't silently flip the mount to read-only. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/noderesource/noderesource.go | 1 + internal/noderesource/noderesource_test.go | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index db018d8..51ae2c4 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -554,6 +554,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}, ) diff --git a/internal/noderesource/noderesource_test.go b/internal/noderesource/noderesource_test.go index 915f233..4015ca5 100644 --- a/internal/noderesource/noderesource_test.go +++ b/internal/noderesource/noderesource_test.go @@ -1053,6 +1053,13 @@ func TestOperatorKeyring_ValidatorWithoutSecret_TestBackend(t *testing.T) { 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 must mount the data PVC — generate-gentx writes the operator key there and sign-and-broadcast reads it back") + g.Expect(dataMount.MountPath).To(Equal(dataDir)) + g.Expect(dataMount.ReadOnly).To(BeFalse(), + "data mount must be read-write so generate-gentx can write $SEI_HOME/keyring-test/") } // TestOperatorKeyring_NonValidator_NoEnv guards the non-validator branch: From 370e535e86a1510338f43ee87bea221041935503 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 19 May 2026 19:33:01 -0700 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20tighten=20operator-keyring=20commen?= =?UTF-8?q?ts=20=E2=80=94=20strong=20impact,=20intentional=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop scaffolding and redundancy from the const docs, operatorKeyringEnvVars doc, OperatorKeyringSource docstring, test-func comments, and assertion messages. Same load-bearing content; fewer words. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/v1alpha1/validator_types.go | 28 ++++------ config/crd/sei.io_seinodedeployments.yaml | 5 +- config/crd/sei.io_seinodes.yaml | 5 +- internal/noderesource/noderesource.go | 65 +++++++--------------- internal/noderesource/noderesource_test.go | 25 ++++----- manifests/sei.io_seinodedeployments.yaml | 5 +- manifests/sei.io_seinodes.yaml | 5 +- 7 files changed, 51 insertions(+), 87 deletions(-) diff --git a/api/v1alpha1/validator_types.go b/api/v1alpha1/validator_types.go index b8c9a5a..ab483c6 100644 --- a/api/v1alpha1/validator_types.go +++ b/api/v1alpha1/validator_types.go @@ -135,26 +135,20 @@ type SecretNodeKeySource struct { SecretName string `json:"secretName"` } -// OperatorKeyringSource configures the source of a validator's operator- -// account keyring — the keyring the sidecar uses to sign governance, -// MsgEditValidator, withdraw-rewards, and other operator-account -// transactions. +// OperatorKeyringSource configures the keyring the sidecar uses to sign +// operator-account transactions (governance, MsgEditValidator, withdraw- +// rewards, etc). // -// With this field unset, the controller wires the sidecar to a test- -// backend keyring at $SEI_HOME/keyring-test/ on the shared data PVC. This -// is the same path the generate-gentx task writes the validator key to -// during a genesis ceremony, so genesis-provisioned validators sign -// without additional configuration. The test backend is unencrypted. +// 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 to source the keyring from a passphrase-locked Secret -// projected into the sidecar (file backend). This is the path for -// operators whose operator key is rotated externally, sourced from an -// HSM-export, or shared across infrastructure the SeiNode controller -// doesn't own. +// 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 sources the keyring from a Cosmos SDK file-backend Kubernetes - // Secret in the SeiNode's namespace, projected into the sidecar at - // $SEI_HOME/keyring-file/. + // 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 5434712..7923eb8 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -685,9 +685,8 @@ spec: properties: secret: description: |- - Secret sources the keyring from a Cosmos SDK file-backend Kubernetes - Secret in the SeiNode's namespace, projected into the sidecar at - $SEI_HOME/keyring-file/. + 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 diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index 2f50b84..f6b125e 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -556,9 +556,8 @@ spec: properties: secret: description: |- - Secret sources the keyring from a Cosmos SDK file-backend Kubernetes - Secret in the SeiNode's namespace, projected into the sidecar at - $SEI_HOME/keyring-file/. + 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 diff --git a/internal/noderesource/noderesource.go b/internal/noderesource/noderesource.go index 51ae2c4..89001fb 100644 --- a/internal/noderesource/noderesource.go +++ b/internal/noderesource/noderesource.go @@ -74,24 +74,15 @@ const ( nodeKeyDataKey = "node_key.json" operatorKeyringVolumeName = "operator-keyring" - // operatorKeyringDirName mirrors the Cosmos SDK file-backend subdirectory: - // keyring.New(name, BackendFile, rootDir, ...) opens rootDir/keyring-file/. - // Used as the mount path for the projected Secret on the .secret path. + // 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" - // keyringBackendEnvVar selects the Cosmos SDK keyring backend the - // sidecar uses for operator-account signing. Must be set explicitly — - // the SDK's compile-time default ("os" on workstations) has no - // distroless analogue and would prevent the sidecar from opening any - // keyring at all. + // Required — the SDK's "os" compile-time default has no distroless analogue. keyringBackendEnvVar = "SEI_KEYRING_BACKEND" - // keyringDirEnvVar carries the keyring root directory. The SDK appends - // "keyring-" to whatever this points at — so passing $SEI_HOME - // makes the file backend resolve to $SEI_HOME/keyring-file/ and the test - // backend to $SEI_HOME/keyring-test/. Always emitted to make the path - // resolution explicit rather than relying on the sidecar's compile-time - // default. + // 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" @@ -279,13 +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. // -// Scoped to the .secret path. The default test-backend keyring lives at -// $SEI_HOME/keyring-test/ on the shared data PVC, which seid necessarily -// mounts to read its config and write chain state — there is no separate -// volume or passphrase to leak. The trust model on that path explicitly -// concedes the PVC to seid, so this guard has nothing to assert and returns -// nil. Operators who need the keyring isolated from seid set .secret, which -// engages this guard. +// 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 { @@ -910,32 +897,22 @@ func operatorKeyringMounts(node *seiv1alpha1.SeiNode) []corev1.VolumeMount { }} } -// operatorKeyringEnvVars configures the sidecar's keyring backend for -// operator-account signing. The branch fires on validator role and whether -// the validator declares a Secret-backed keyring source: +// operatorKeyringEnvVars configures the sidecar's keyring. Branches: // -// - Non-validator: no env vars. The sidecar has nothing to sign for. -// - Validator with .secret: file backend. SEI_KEYRING_BACKEND=file plus -// SEI_KEYRING_PASSPHRASE sourced from the referenced passphrase Secret. -// operatorKeyringVolumes projects the keyring data Secret as a directory -// at $SEI_HOME/keyring-file/; the SDK's file backend resolves there. -// - Validator without .secret: test backend rooted at $SEI_HOME. The SDK -// appends "keyring-test" and the sidecar reads $SEI_HOME/keyring-test/ -// on the shared data PVC — the same path the generate-gentx task writes -// the validator key to during a genesis ceremony. No passphrase; the -// test backend is unencrypted. Operators who want passphrase-locked, -// projected-Secret semantics on the same node set .secret. +// - 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. // -// Both validator branches emit SEI_KEYRING_DIR explicitly. The SDK appends -// the "keyring-" suffix itself, so the controller passes the root -// ($SEI_HOME) and lets the SDK resolve. This keeps SDK directory-layout -// knowledge out of the controller and the resolution behavior symmetric -// between the two backends. +// SEI_KEYRING_DIR is $SEI_HOME on both validator branches; the SDK appends +// "keyring-" itself. // -// The passphrase Secret is separate from the keyring data 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 and the file backend would treat it as keyring contents. +// 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 diff --git a/internal/noderesource/noderesource_test.go b/internal/noderesource/noderesource_test.go index 4015ca5..61114b6 100644 --- a/internal/noderesource/noderesource_test.go +++ b/internal/noderesource/noderesource_test.go @@ -1015,9 +1015,9 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) { "operator-keyring is a directory mount, not subPath — sidecar needs the whole dir") g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(Equal(keyringBackendFile), - "sidecar must have SEI_KEYRING_BACKEND=file so the SDK opens the projected Secret as a file-backend keyring; without this the SDK falls back to its compile-time default which has no distroless analogue") + "SEI_KEYRING_BACKEND=file required; SDK 'os' default has no distroless analogue") g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir), - "SEI_KEYRING_DIR must point at $SEI_HOME — the SDK appends keyring-file/ itself; passing a deeper path makes the SDK look at $SEI_HOME/keyring-file/keyring-file/") + "SEI_KEYRING_DIR=$SEI_HOME; SDK appends keyring-file/ (doubled path otherwise)") var passphraseEnv *corev1.EnvVar for i := range sidecar.Env { @@ -1032,10 +1032,9 @@ func TestOperatorKeyring_SidecarContainerHasMountAndEnv(t *testing.T) { g.Expect(passphraseEnv.ValueFrom.SecretKeyRef.Key).To(Equal("passphrase")) } -// TestOperatorKeyring_ValidatorWithoutSecret_TestBackend covers the implicit -// default: a validator without .secret gets a test-backend keyring rooted at -// $SEI_HOME so the SDK resolves to $SEI_HOME/keyring-test/, where the -// generate-gentx task writes the validator key during the genesis ceremony. +// 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") @@ -1045,26 +1044,24 @@ func TestOperatorKeyring_ValidatorWithoutSecret_TestBackend(t *testing.T) { g.Expect(sidecar).NotTo(BeNil(), "sei-sidecar init container must exist") g.Expect(envValue(sidecar.Env, keyringBackendEnvVar)).To(Equal(keyringBackendTest), - "validators without .secret default to the test backend so the sidecar reads the gentx-written keyring on the data PVC") + "default test backend reads the gentx-written keyring on the data PVC") g.Expect(envValue(sidecar.Env, keyringDirEnvVar)).To(Equal(dataDir), - "SEI_KEYRING_DIR must be $SEI_HOME — the SDK appends keyring-test/ itself; passing $SEI_HOME/keyring-test makes the SDK resolve $SEI_HOME/keyring-test/keyring-test/") + "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 should be set") + "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 must mount the data PVC — generate-gentx writes the operator key there and sign-and-broadcast reads it back") + "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 read-write so generate-gentx can write $SEI_HOME/keyring-test/") + "data mount must be RW for generate-gentx to write $SEI_HOME/keyring-test/") } -// TestOperatorKeyring_NonValidator_NoEnv guards the non-validator branch: -// SeiNodes without a Validator spec (full nodes, snapshots) must not receive -// keyring env vars — they have nothing to sign for. +// 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") diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 5434712..7923eb8 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -685,9 +685,8 @@ spec: properties: secret: description: |- - Secret sources the keyring from a Cosmos SDK file-backend Kubernetes - Secret in the SeiNode's namespace, projected into the sidecar at - $SEI_HOME/keyring-file/. + 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 diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index 2f50b84..f6b125e 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -556,9 +556,8 @@ spec: properties: secret: description: |- - Secret sources the keyring from a Cosmos SDK file-backend Kubernetes - Secret in the SeiNode's namespace, projected into the sidecar at - $SEI_HOME/keyring-file/. + 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