Skip to content
Draft
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
142 changes: 142 additions & 0 deletions cmd/cli/commands/consensus/keys/init.go
Original file line number Diff line number Diff line change
@@ -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 <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
}
126 changes: 126 additions & 0 deletions cmd/cli/commands/consensus/keys/init_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions cmd/cli/commands/consensus/keys/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions docs/commands/consensus.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.