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
File renamed without changes.
File renamed without changes.
82 changes: 82 additions & 0 deletions internal/keygen/keygen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Package keygen derives a Sei chain account: a BIP-39 mnemonic + the cosmos
// secp256k1 address at the standard coin-type-118 path, bech32-encoded with the
// "sei" prefix. The full pipeline (entropy → mnemonic → seed → BIP-32 master →
// BIP-44 child → secp256k1 → ripemd160 → bech32) matches `seid keys add`
// byte-for-byte, so a mnemonic generated here imports verbatim into a seid
// keyring.
//
// This is the general, k8s-free derivation primitive. Callers that need to stamp
// the result into a Secret / workflow-vars layer sit on top of it — see
// internal/seitask/keygen for the seitask-runner's Secret writer.
package keygen

import (
"crypto/sha256"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/cosmos/btcutil/bech32"
bip39 "github.com/cosmos/go-bip39"
"golang.org/x/crypto/ripemd160" //nolint:staticcheck // Cosmos address derivation is bound to RIPEMD-160 by protocol.
)

// cosmosHDPath is the cosmos BIP-44 path, coin type 118. Matches `seid keys add`.
const cosmosHDPath = "m/44'/118'/0'/0/0"

const bech32AccountPrefix = "sei"

// SecretMnemonicKey is the conventional Secret data key the mnemonic is stored
// under; downstream pods reference it via secretKeyRef.
const SecretMnemonicKey = "mnemonic"

// Identity is a derived account: the mnemonic that produced it and its bech32
// address. The mnemonic is the secret material; treat it accordingly.
type Identity struct {
Mnemonic string
Address string
}

// Derive generates a 24-word BIP-39 mnemonic and derives the cosmos secp256k1
// address at m/44'/118'/0'/0/0.
func Derive() (Identity, error) {
// 24 words → 256 bits entropy; matches seid default.
entropy, err := bip39.NewEntropy(256)
if err != nil {
return Identity{}, fmt.Errorf("entropy: %w", err)
}
mnemonic, err := bip39.NewMnemonic(entropy)
if err != nil {
return Identity{}, fmt.Errorf("mnemonic: %w", err)
}

// BIP-39 PBKDF2 → 64-byte seed; empty passphrase matches seid default.
seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
if err != nil {
return Identity{}, fmt.Errorf("seed: %w", err)
}
master, chainCode := computeMasterFromSeed(seed)
privKey, err := derivePrivateKeyForPath(master, chainCode, cosmosHDPath)
if err != nil {
return Identity{}, fmt.Errorf("derive %s: %w", cosmosHDPath, err)
}
_, pub := btcec.PrivKeyFromBytes(privKey)
pubCompressed := pub.SerializeCompressed()

// Cosmos address = ripemd160(sha256(pubkey_compressed)), bech32-encoded.
sha := sha256.Sum256(pubCompressed)
hasher := ripemd160.New()
if _, err := hasher.Write(sha[:]); err != nil {
return Identity{}, fmt.Errorf("ripemd160: %w", err)
}
addrBytes := hasher.Sum(nil)

converted, err := bech32.ConvertBits(addrBytes, 8, 5, true)
if err != nil {
return Identity{}, fmt.Errorf("bech32 convert: %w", err)
}
address, err := bech32.Encode(bech32AccountPrefix, converted)
if err != nil {
return Identity{}, fmt.Errorf("bech32 encode: %w", err)
}
return Identity{Mnemonic: mnemonic, Address: address}, nil
}
81 changes: 11 additions & 70 deletions internal/seitask/keygen/keygen.go
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
// Package keygen implements `seitask keygen`: generate a BIP-39 mnemonic +
// cosmos secp256k1 keypair, write the mnemonic to a per-run Secret named
// Package keygen implements `seitask keygen`: derive a Sei account via the
// general internal/keygen primitive, write the mnemonic to a per-run Secret named
// "<keyName>-<workflowName>", and publish ADMIN_ADDRESS / ADMIN_SECRET_NAME
// to workflow-vars. All created resources carry an ownerRef to the parent
// Workflow CR for cascade GC.
// Workflow CR for cascade GC. The key derivation itself lives in
// internal/keygen (k8s-free, reused by the test harness); this package is the
// seitask-runner's Secret/workflow-vars writer on top of it.
package keygen

import (
"context"
"crypto/sha256"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/cosmos/btcutil/bech32"
bip39 "github.com/cosmos/go-bip39"
"golang.org/x/crypto/ripemd160" //nolint:staticcheck // Cosmos address derivation is bound to RIPEMD-160 by protocol.

corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

keyderive "github.com/sei-protocol/sei-k8s-controller/internal/keygen"
"github.com/sei-protocol/sei-k8s-controller/internal/taskruntime"
)

// cosmos BIP-44 path, coin type 118. Matches `seid keys add` so mnemonics
// generated here import verbatim into a seid keyring.
const cosmosHDPath = "m/44'/118'/0'/0/0"

const bech32AccountPrefix = "sei"

// SecretMnemonicKey is the data key downstream pods reference via secretKeyRef.
const SecretMnemonicKey = "mnemonic"

const fieldOwner client.FieldOwner = "seitask-keygen"

// Params carries the typed inputs to Run.
Expand Down Expand Up @@ -79,7 +67,7 @@ func Run(ctx context.Context, c client.Client, p Params) (Result, error) {
return Result{}, taskruntime.Infra(fmt.Errorf("reading existing Secret %q: %w", secretName, err))
}

mnemonic, address, err := deriveIdentity()
id, err := keyderive.Derive()
if err != nil {
return Result{}, taskruntime.Infra(fmt.Errorf("deriving identity: %w", err))
}
Expand All @@ -92,11 +80,11 @@ func Run(ctx context.Context, c client.Client, p Params) (Result, error) {
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
SecretMnemonicKey: []byte(mnemonic),
keyderive.SecretMnemonicKey: []byte(id.Mnemonic),
// address is duplicated into the Secret so a re-run of keygen
// can reuse the existing identity without re-deriving from the
// mnemonic (the Secret is the source of truth for both).
"address": []byte(address),
"address": []byte(id.Address),
},
}
if err := c.Create(ctx, secret, fieldOwner); err != nil {
Expand All @@ -108,10 +96,10 @@ func Run(ctx context.Context, c client.Client, p Params) (Result, error) {
return Result{}, taskruntime.Infra(fmt.Errorf("creating Secret %q: %w", secretName, err))
}

if err := writeWorkflowVars(ctx, c, p.Workflow, address, secretName); err != nil {
if err := writeWorkflowVars(ctx, c, p.Workflow, id.Address, secretName); err != nil {
return Result{}, err
}
return Result{SecretName: secretName, Address: address}, nil
return Result{SecretName: secretName, Address: id.Address}, nil
}

func writeWorkflowVars(ctx context.Context, c client.Client, w taskruntime.WorkflowIdentity, address, secretName string) error {
Expand All @@ -125,50 +113,3 @@ func writeWorkflowVars(ctx context.Context, c client.Client, w taskruntime.Workf
taskruntime.KeyAdminSecretName: secretName,
})
}

// deriveIdentity generates a 24-word BIP-39 mnemonic and derives the cosmos
// secp256k1 address at m/44'/118'/0'/0/0. The full pipeline (entropy →
// mnemonic → seed → BIP-32 master → BIP-44 child → secp256k1 → ripemd160
// → bech32) matches `seid keys add` byte-for-byte.
func deriveIdentity() (mnemonic, address string, err error) {
// 24 words → 256 bits entropy; matches seid default.
entropy, err := bip39.NewEntropy(256)
if err != nil {
return "", "", fmt.Errorf("entropy: %w", err)
}
mnemonic, err = bip39.NewMnemonic(entropy)
if err != nil {
return "", "", fmt.Errorf("mnemonic: %w", err)
}

// BIP-39 PBKDF2 → 64-byte seed; empty passphrase matches seid default.
seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
if err != nil {
return "", "", fmt.Errorf("seed: %w", err)
}
master, chainCode := computeMasterFromSeed(seed)
privKey, err := derivePrivateKeyForPath(master, chainCode, cosmosHDPath)
if err != nil {
return "", "", fmt.Errorf("derive %s: %w", cosmosHDPath, err)
}
_, pub := btcec.PrivKeyFromBytes(privKey)
pubCompressed := pub.SerializeCompressed()

// Cosmos address = ripemd160(sha256(pubkey_compressed)), bech32-encoded.
sha := sha256.Sum256(pubCompressed)
hasher := ripemd160.New()
if _, err := hasher.Write(sha[:]); err != nil {
return "", "", fmt.Errorf("ripemd160: %w", err)
}
addrBytes := hasher.Sum(nil)

converted, err := bech32.ConvertBits(addrBytes, 8, 5, true)
if err != nil {
return "", "", fmt.Errorf("bech32 convert: %w", err)
}
address, err = bech32.Encode(bech32AccountPrefix, converted)
if err != nil {
return "", "", fmt.Errorf("bech32 encode: %w", err)
}
return mnemonic, address, nil
}
3 changes: 2 additions & 1 deletion internal/seitask/keygen/keygen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

keyderive "github.com/sei-protocol/sei-k8s-controller/internal/keygen"
"github.com/sei-protocol/sei-k8s-controller/internal/taskruntime"
)

Expand Down Expand Up @@ -58,7 +59,7 @@ func TestRun_CreatesSecretAndWorkflowVars(t *testing.T) {
if err := c.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testSecretName}, secret); err != nil {
t.Fatalf("Get Secret: %v", err)
}
mnemonic, ok := secret.Data[SecretMnemonicKey]
mnemonic, ok := secret.Data[keyderive.SecretMnemonicKey]
if !ok || len(mnemonic) == 0 {
t.Fatalf("mnemonic missing from Secret")
}
Expand Down
1 change: 1 addition & 0 deletions sdk/sei/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type NodeHandle interface {
Namespace() string
EVMRPC() string
TendermintRPC() string
REST() string
WaitReady(ctx context.Context) error
Delete(ctx context.Context) error
Object() any // mode-specific raw resource (k8s: *v1alpha1.SeiNode)
Expand Down
7 changes: 7 additions & 0 deletions sdk/sei/provider/k8s/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ func (h *nodeHandle) TendermintRPC() string {
return h.node.Status.Endpoint.TendermintRpc
}

func (h *nodeHandle) REST() string {
if h.node == nil || h.node.Status.Endpoint == nil {
return ""
}
return h.node.Status.Endpoint.TendermintRest
}

// WaitReady blocks until the SeiNode reaches PhaseRunning and a light serve-probe
// passes, failing fast on PhaseFailed. The caller's ctx is the budget.
func (h *nodeHandle) WaitReady(ctx context.Context) error {
Expand Down
13 changes: 13 additions & 0 deletions sdk/sei/readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ func WaitEVMServing(ctx context.Context, hc *http.Client, evmRPC string) error {
})
}

// WaitRESTServing blocks until restURL answers the Cosmos REST node-info endpoint
// with HTTP 200 — proof the LCD listener is bound and serving. A node's status
// advertises its REST URL as soon as the endpoint is composed, but the LCD API
// binds later in seid boot than the EVM listener, so a freshly-Running node can
// advertise REST before it serves; this gates on an actual answer. hc may be nil.
func WaitRESTServing(ctx context.Context, hc *http.Client, restURL string) error {
url := restURL + "/cosmos/base/tendermint/v1beta1/node_info"
return pollUntil(ctx, url, func(ctx context.Context) bool {
_, ok := getJSON(ctx, hc, http.MethodGet, url, "")
return ok
})
}

// pollUntil ticks done() every probeInterval until it returns true or ctx fires,
// running once immediately. A stdlib poll loop — no apimachinery in core.
func pollUntil(ctx context.Context, what string, done func(context.Context) bool) error {
Expand Down
4 changes: 4 additions & 0 deletions sdk/sei/sei.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ func (n *Node) EVMRPC() string { return n.handle.EVMRPC() }
// TendermintRPC is the node's Tendermint RPC URL off .status.
func (n *Node) TendermintRPC() string { return n.handle.TendermintRPC() }

// REST is the node's Cosmos REST (LCD) URL off .status; "" unless the node
// serves REST (fullNode RPCs do; bare validators do not unless configured).
func (n *Node) REST() string { return n.handle.REST() }

// WaitReady blocks until the node reaches the Running phase and a light serve-
// probe passes, or the caller's ctx fires (IsTimeout on a deadline).
func (n *Node) WaitReady(ctx context.Context) error { return n.handle.WaitReady(ctx) }
Expand Down
40 changes: 40 additions & 0 deletions test/integration/.xreview/release-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# xreview ledger — TestRelease + keygen refactor (WS-I)

Class: component (integration suite + internal package refactor + additive SDK surface)
Tier: T2

Target: `test/integration/release_test.go`, `internal/keygen/*`, `internal/seitask/keygen/keygen.go`, `sdk/sei` Node.REST()
Artifact: branch `feat/test-release`

## Round 1

State: RESOLVED
OpenFindings: 0
Convergence: independent (4 blinded reviewers)
Blinded: yes
Dissenter: sei-network-specialist (DISSENT → resolved)

Slate: sei-network-specialist (dissenter), systems-engineer, kubernetes-specialist, idiomatic-reviewer.

### Findings

| Finding | Status | Evidence | Raised by | Resolution |
|---|---|---|---|---|
| Dropped envFrom / RPC_EVM_RPC_LIST | **MISMATCH → FIXED** | Scenario injects env via `envFrom: workflow-vars CM` ∪ explicit list; the CM carries RPC_EVM_RPC_LIST (+ RPC_*/CHAIN_ID/ADMIN_ADDRESS) with no explicit equivalent. A harness sub-case reading it would skip silently → exit 0 false-pass. | dissenter (headline) | Job env now reproduces the scenario superset: the RPC_*/CHAIN_ID/ADMIN_ADDRESS CM names alongside the SEI_* explicit names. |
| Verdict is exit-0-only | **MISSING → FIXED** | No record of which sub-cases ran; scenario had upload-report (S3 audit). Strictly less observable than the artifact. | dissenter | Log the harness pod-log tail on completion (success too), so a skip-but-exit-0 is forensically visible. (Full S3/report = the deferred telemetry component.) |
| Job missing securityContext/resources/ttl | **MISMATCH → FIXED** | seiload_job.yaml.tmpl sets runAsNonRoot/seccomp/drop-ALL/readOnlyRootFS + resources + ttl; releaseJob set none → restricted-PSS admission could reject. | k8s | releaseJob now matches the seiload baseline (security context, resources, ttlSecondsAfterFinished). |
| REST handed unprobed (cold-start) | **MISSING → FIXED** | rest=="" is a status-string check, not a serve-probe; LCD binds later than the EVM listener → cold-REST window. | systems, k8s, dissenter | Added sei.WaitRESTServing (GET /cosmos/base/tendermint/v1beta1/node_info), symmetric with WaitEVMServing; replaces the bare empty-check. |
| waitJob drops podLogTail on ctx/signal | **MISMATCH → FIXED** | ctx.Done() branch failed with only ctx.Err() — no harness log on Ctrl-C/SIGTERM/timeout. | systems | ctx.Done() branch now tails the pod log (fresh ctx); messages genericized from "seiload job" to "job". |
| releaseBaseConfig handed un-cloned | **flag → FIXED** | provision maps.Clone's config; release passed the package-global directly. | systems | maps.Clone at the network create. |
| REST on by default for fullNode | COMPATIBLE | Smoke-confirmed (`...:1317` populated); ModeFull → REST.Enable=true, validators → false (explains the upgrade-suite gap). | dissenter (refutes own attack) | — |
| Secret material handling | COMPATIBLE | secretKeyRef (not plain env), never logged, Data not StringData. | systems | — |
| keygen refactor behavior | COMPATIBLE | Pure extraction verified line-by-line (entropy/path/pipeline/idempotency/ownerRef preserved). | k8s, idiom | — |
| Single RPC + EVM-legacy + funding + namespace | COMPATIBLE | One-node filter consistency correct; 1e12 usei matches scenario; co-located. | dissenter, k8s | — |
| Suite SA needs secrets RBAC | RESOLVED (Brandon-authorized) | createMnemonicSecret needs secrets create/delete; smoke confirmed Forbidden without it. | k8s, systems | Granted to the harness Role; the committed manifest lands in the cutover. |

### Idiom addendum (RATIFY)
Clean. The Go-built Job (vs seiload's template) is principled (shape owned by the suite, not platform) — do NOT harmonize. Node.REST() additive-safe, mirrors EVMRPC/TendermintRPC. Nits fixed: keygen.go:9 typo. SecretMnemonicKey placement vet-and-rejected (one shared const).

### Deferred
- Full upload-report / S3 audit trail = the deferred telemetry/report component (PromQL punted to last); the pod-log tail is the interim observability.
- Secret leak on SIGKILL until the label-GC sweep ships (cutover) — documented; mnemonic is for a throwaway chain (DeletionDelete cascade).
Loading
Loading