diff --git a/cmd/cli/commands/consensus/keys/init.go b/cmd/cli/commands/consensus/keys/init.go
new file mode 100644
index 00000000..ee5b86f8
--- /dev/null
+++ b/cmd/cli/commands/consensus/keys/init.go
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package keys
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/automa-saga/errx"
+ "github.com/automa-saga/logx"
+ cnkeys "github.com/hashgraph/solo-weaver/internal/consensus/keys"
+ corekeys "github.com/hashgraph/solo-weaver/pkg/consensus/keys"
+ "github.com/hashgraph/solo-weaver/pkg/reasons"
+ "github.com/joomcode/errorx"
+ "github.com/spf13/cobra"
+)
+
+var (
+ readInitGrpcFlags func() (corekeys.GrpcOptions, error)
+
+ flagInitKeysDir string
+ flagInitIdentity string
+ flagInitMachineID string
+ flagInitForce bool
+
+ initSelectors func() cnkeys.Selectors
+
+ initCmd = &cobra.Command{
+ Args: cobra.NoArgs,
+ Use: "init",
+ Short: "Generate a node's key material and create its secrets in one step",
+ Long: "One-shot convenience for a node whose id is already known (genesis / known assignment): " +
+ "generate the key material into --keys-dir, then create the per-node Kubernetes secrets from it. " +
+ "Equivalent to `keys generate` followed by `keys import --node-id N`, reusing the same code paths.\n\n" +
+ "For a DAB join the node-id is not known until the network assigns it, so use `keys generate` " +
+ "(node-id-free) then `keys import` separately instead.\n\n" +
+ "The admin key is not generated: create it in an external wallet and save its public key (hex) as " +
+ "admin.pub in --keys-dir before running init. With no selector flag, the admin secret is created " +
+ "only when admin.pub exists; pass --admin to require it.",
+ RunE: runInit,
+ }
+)
+
+func init() {
+ initSelectors = addSelectorFlags(initCmd)
+ initCmd.Flags().StringVar(&flagInitKeysDir, "keys-dir", "", "Directory for this node's generated key material (required); make it node-specific")
+ initCmd.Flags().StringVar(&flagInitIdentity, "identity", "", "Optional human-readable label recorded in the gossip certificate OU/SAN")
+ initCmd.Flags().StringVar(&flagInitMachineID, "machine-id", "", "Override the gossip certificate CN; defaults to the host machine UUID (/etc/machine-id)")
+ readInitGrpcFlags = addGrpcFlags(initCmd)
+ initCmd.Flags().BoolVar(&flagInitForce, "force", false, "Overwrite the gossip signing secret if it already exists (changes the node's network identity)")
+}
+
+func runInit(cmd *cobra.Command, _ []string) error {
+ if strings.TrimSpace(flagInitKeysDir) == "" {
+ return errx.Decorate(
+ errorx.IllegalArgument.New("--keys-dir is required"),
+ reasons.InvalidArgument,
+ "pass --keys-dir
(a node-specific directory) for the generated key material")
+ }
+
+ grpcOpts, err := readInitGrpcFlags()
+ if err != nil {
+ return err
+ }
+
+ namespace, err := requireNamespace(cmd)
+ if err != nil {
+ return err
+ }
+ nodeID, err := requireNodeID(cmd)
+ if err != nil {
+ return err
+ }
+
+ selectors := initSelectors()
+
+ // 1. Generate into --keys-dir (same path as `keys generate`). Idempotent:
+ // existing key types are kept unless --force, so re-running init is safe.
+ toGen, need := planGeneration(flagInitKeysDir, selectors, flagInitForce)
+ if need {
+ set, err := cnkeys.Generate(cnkeys.GenerateOptions{
+ Selectors: toGen,
+ Identity: flagInitIdentity,
+ MachineID: flagInitMachineID,
+ Grpc: grpcOpts,
+ })
+ if err != nil {
+ return errx.Decorate(err, reasons.Internal)
+ }
+ if err := set.WriteToDir(flagInitKeysDir); err != nil {
+ return errx.Decorate(err, reasons.Internal,
+ fmt.Sprintf("ensure %q is writable and has enough space", flagInitKeysDir))
+ }
+ }
+
+ // 2. Import from --keys-dir (same path as `keys import`).
+ client, err := newSecretClient(cmd.Context())
+ if err != nil {
+ return errx.Decorate(
+ errorx.IllegalState.Wrap(err, "connect to the Kubernetes cluster"),
+ reasons.PreconditionNotMet,
+ "ensure a cluster is reachable (KUBECONFIG or in-cluster config)")
+ }
+ res, err := cnkeys.Import(cmd.Context(), client, cnkeys.ImportOptions{
+ Namespace: namespace,
+ NodeId: nodeID,
+ FromDir: flagInitKeysDir,
+ Selectors: selectors,
+ Force: flagInitForce,
+ })
+ if err != nil {
+ return errx.Decorate(err, reasons.Internal,
+ fmt.Sprintf("the key material was generated in %q; re-run `keys import` from there once the cause is fixed", flagInitKeysDir))
+ }
+
+ logx.As().Info().Str("namespace", namespace).Int64("nodeId", nodeID).Str("keysDir", flagInitKeysDir).Strs("secrets", res.Applied).Msg("Initialized consensus node keys")
+
+ cmd.Printf("Initialized key material for node%d in namespace %s\n", nodeID, namespace)
+ cmd.Printf(" generated in: %s\n", flagInitKeysDir)
+ for _, name := range res.Applied {
+ cmd.Printf(" secret/%s\n", name)
+ }
+ for _, name := range res.Skipped {
+ cmd.Printf(" secret/%s (already up to date)\n", name)
+ }
+ if !hasAdminSecret(res) {
+ cmd.Println("No admin public key was imported: save it from your wallet as " + cnkeys.FileAdminPublic + " in --keys-dir, then run `keys import --admin`.")
+ }
+ return nil
+}
+
+// hasAdminSecret reports whether the admin public key secret was among the secrets handled.
+func hasAdminSecret(res *cnkeys.ImportResult) bool {
+ for _, names := range [][]string{res.Applied, res.Relabeled, res.Skipped} {
+ for _, n := range names {
+ if strings.Contains(n, "-admin-pubkey-") {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/cmd/cli/commands/consensus/keys/init_test.go b/cmd/cli/commands/consensus/keys/init_test.go
new file mode 100644
index 00000000..d9ddbdc6
--- /dev/null
+++ b/cmd/cli/commands/consensus/keys/init_test.go
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package keys
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ cnkeys "github.com/hashgraph/solo-weaver/internal/consensus/keys"
+ corekeys "github.com/hashgraph/solo-weaver/pkg/consensus/keys"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInitCmd_HappyPath(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "keys")
+ fake := &fakeSecretClient{store: map[string]map[string][]byte{}}
+ stubSecretClient(t, fake)
+
+ out, err := runUnderNode(t, initCmd, "--namespace", "orbit-1", "--node-id", "0", "--keys-dir", dir, "--machine-id", "m")
+ require.NoError(t, err)
+
+ // Generated the folder AND created the three secrets in one invocation.
+ assert.FileExists(t, filepath.Join(dir, cnkeys.FileGossipPrivate))
+ assert.NoFileExists(t, filepath.Join(dir, "admin.key"), "init never generates an admin key")
+ assert.True(t, fake.hasSecretWithPrefix("orbit-1", "node0-gossip-keys-"))
+ assert.True(t, fake.hasSecretWithPrefix("orbit-1", "node0-grpc-tls-keys-"))
+
+ // No admin.pub was supplied, so no admin secret is created and the user is told why.
+ assert.False(t, fake.hasSecretWithPrefix("orbit-1", "node0-admin-pubkey-"))
+ assert.Contains(t, out, "No admin public key was imported")
+ assert.Contains(t, out, "secret/node0-gossip-keys-")
+}
+
+func TestInitCmd_ImportsSuppliedAdminPublicKey(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "keys")
+ require.NoError(t, os.MkdirAll(dir, 0o700))
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, cnkeys.FileAdminPublic), []byte(hex.EncodeToString(pub)+"\n"), 0o644))
+ fake := &fakeSecretClient{store: map[string]map[string][]byte{}}
+ stubSecretClient(t, fake)
+
+ out, err := runUnderNode(t, initCmd, "--namespace", "orbit-1", "--node-id", "0", "--keys-dir", dir, "--machine-id", "m")
+ require.NoError(t, err)
+
+ assert.True(t, fake.hasSecretWithPrefix("orbit-1", "node0-admin-pubkey-"))
+ assert.NotContains(t, out, "No admin public key was imported")
+ for k, admin := range fake.store {
+ if strings.HasPrefix(k, "orbit-1/node0-admin-pubkey-") {
+ assert.Equal(t, []byte(pub), admin[cnkeys.FileAdminPublic])
+ }
+ }
+}
+
+func TestInitCmd_RequiresKeysDir(t *testing.T) {
+ stubSecretClient(t, &fakeSecretClient{store: map[string]map[string][]byte{}})
+ _, err := runUnderNode(t, initCmd, "--namespace", "ns", "--node-id", "0")
+ require.Error(t, err)
+}
+
+func TestInitCmd_RequiresNodeId(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "keys")
+ stubSecretClient(t, &fakeSecretClient{store: map[string]map[string][]byte{}})
+ _, err := runUnderNode(t, initCmd, "--namespace", "ns", "--keys-dir", dir, "--machine-id", "m")
+ require.Error(t, err)
+}
+
+func TestInitCmd_Idempotent(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "keys")
+ fake := &fakeSecretClient{store: map[string]map[string][]byte{}}
+ stubSecretClient(t, fake)
+
+ _, err := runUnderNode(t, initCmd, "--namespace", "ns", "--node-id", "0", "--keys-dir", dir, "--machine-id", "m")
+ require.NoError(t, err)
+ first, err := os.ReadFile(filepath.Join(dir, cnkeys.FileGossipPrivate))
+ require.NoError(t, err)
+
+ // Re-running init keeps the existing key material (no regeneration) and does
+ // not fail on the already-present gossip secret.
+ _, err = runUnderNode(t, initCmd, "--namespace", "ns", "--node-id", "0", "--keys-dir", dir, "--machine-id", "m")
+ require.NoError(t, err)
+ again, err := os.ReadFile(filepath.Join(dir, cnkeys.FileGossipPrivate))
+ require.NoError(t, err)
+ assert.Equal(t, string(first), string(again))
+}
+
+func TestInitCmd_EquivalentToGenerateThenImport(t *testing.T) {
+ // init with a selector subset generates + imports only that type.
+ dir := filepath.Join(t.TempDir(), "keys")
+ fake := &fakeSecretClient{store: map[string]map[string][]byte{}}
+ stubSecretClient(t, fake)
+
+ _, err := runUnderNode(t, initCmd, "--namespace", "ns", "--node-id", "1", "--keys-dir", dir, "--machine-id", "m", "--gossip")
+ require.NoError(t, err)
+
+ assert.True(t, fake.hasSecretWithPrefix("ns", "node1-gossip-keys-"))
+ assert.False(t, fake.hasSecretWithPrefix("ns", "node1-grpc-tls-keys-"))
+ assert.False(t, fake.hasSecretWithPrefix("ns", "node1-admin-pubkey-"))
+}
+
+func TestInitCmd_GrpcFlags(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "keys")
+ stubSecretClient(t, &fakeSecretClient{store: map[string]map[string][]byte{}})
+ resetGrpcFlags(t, initCmd)
+ t.Cleanup(func() { resetGrpcFlags(t, initCmd) })
+
+ _, err := runUnderNode(t, initCmd, "--namespace", "ns", "--node-id", "0", "--keys-dir", dir, "--machine-id", "m",
+ "--grpc-dns", "node0.example.org", "--grpc-cn", "node0", "--grpc-org", "Example Org", "--grpc-validity-days", "90")
+ require.NoError(t, err)
+
+ pemBytes, err := os.ReadFile(filepath.Join(dir, cnkeys.FileGrpcCert))
+ require.NoError(t, err)
+ cert, err := corekeys.ParseCertificatePEM(pemBytes)
+ require.NoError(t, err)
+ assert.Equal(t, "node0", cert.Subject.CommonName)
+ assert.Equal(t, []string{"Example Org"}, cert.Subject.Organization)
+ assert.Contains(t, cert.DNSNames, "node0.example.org")
+ assert.Less(t, cert.NotAfter.Sub(cert.NotBefore), 92*24*time.Hour)
+}
diff --git a/cmd/cli/commands/consensus/keys/keys.go b/cmd/cli/commands/consensus/keys/keys.go
index 6d63eb43..2f56eb3d 100644
--- a/cmd/cli/commands/consensus/keys/keys.go
+++ b/cmd/cli/commands/consensus/keys/keys.go
@@ -44,6 +44,7 @@ func init() {
keysCmd.AddCommand(importCmd)
keysCmd.AddCommand(exportCmd)
keysCmd.AddCommand(verifyCmd)
+ keysCmd.AddCommand(initCmd)
}
// GetCmd returns the consensus keys command group.
diff --git a/docs/commands/consensus.md b/docs/commands/consensus.md
index b1db6ea8..5622bac4 100644
--- a/docs/commands/consensus.md
+++ b/docs/commands/consensus.md
@@ -246,3 +246,37 @@ the inherited `--namespace` and `--node-id`. In the cluster, each secret is chec
against its name and fingerprint label (a renamed or edited secret fails), and when a
node has several generations of a key type the failing line lists them so you can
choose with `--fingerprint` or `--secret-suffix`.
+
+### `consensus keys init`
+
+One-shot convenience for a node whose id is already known (genesis / known
+assignment): generate the key material into `--keys-dir`, then create the per-node
+secrets from it. Equivalent to `keys generate` followed by `keys import --node-id
+N`, reusing the same code paths.
+
+For a DAB **join** the node-id is not known until the network assigns it, so use
+`keys generate` (node-id-free) then `keys import` separately instead.
+
+```bash
+sudo solo-provisioner consensus keys init --experimental \
+ --namespace my-orbit --node-id 0 --keys-dir ./keys/node0
+```
+
+| Flag | Description | Default |
+|---|---|---|
+| `--keys-dir` | Directory for this node's generated key material (required); make it node-specific | — |
+| `--identity` | Optional human-readable label recorded in the gossip certificate OU/SAN | — |
+| `--machine-id` | Override the gossip certificate CN; defaults to the host machine UUID (`/etc/machine-id`) | host machine UUID |
+| `--grpc-cn` | Common name (subject and issuer) of the gRPC TLS certificate | `localhost` |
+| `--grpc-org` | Organization (subject and issuer O) of the gRPC TLS certificate | omitted |
+| `--grpc-dns` | Extra fully qualified DNS SAN for the gRPC TLS certificate, added to `localhost` (repeatable) | — |
+| `--grpc-ip` | Extra IP SAN for the gRPC TLS certificate, added to `127.0.0.1` and `::1` (repeatable) | — |
+| `--grpc-validity-days` | gRPC TLS certificate lifetime in days (1-36500) | 100 years |
+| `--force` | Overwrite a same-named secret whose content differs (for example one edited by hand) | `false` |
+| `--gossip` / `--tls` / `--admin` | Select which key types to create | gossip and gRPC, plus admin when `admin.pub` exists |
+
+`--namespace` and `--node-id` are inherited from `consensus keys`; `--node-id` is
+**required** here. `init` never generates an admin key: create it in an external
+wallet and save its public key (hex) as `admin.pub` in `--keys-dir` before running
+`init`. Without that file, no admin secret is created and `init` says so; import it
+later with `keys import --admin`.