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`.