From 355b6ce4a05e7efc0905053b370b4301f7dd1fd3 Mon Sep 17 00:00:00 2001 From: "kuma.jung" Date: Sat, 11 Apr 2026 13:54:52 +0900 Subject: [PATCH] feat: add task-oriented ux commands --- .kiro/feature-recommendations.md | 10 ++ README.md | 15 +++ cmd/ende/crypto_cmd.go | 2 + cmd/ende/key_cmd.go | 190 +++++++++++++++++++------------ cmd/ende/parties_cmd.go | 2 +- cmd/ende/root.go | 2 +- cmd/ende/ux_cmd_test.go | 70 ++++++++++++ 7 files changed, 215 insertions(+), 76 deletions(-) create mode 100644 cmd/ende/ux_cmd_test.go diff --git a/.kiro/feature-recommendations.md b/.kiro/feature-recommendations.md index bc19da1..2fb25dd 100644 --- a/.kiro/feature-recommendations.md +++ b/.kiro/feature-recommendations.md @@ -11,6 +11,7 @@ - The next milestone should prioritize reducing operator mistakes, not just adding platform breadth. - The highest-leverage improvements are the ones that increase confidence at the moment of encrypt/decrypt. - Near-term roadmap should focus on: masked secret entry, preflight diagnostics, recipient confirmation, and safer plaintext output handling. +- A second UX layer should expose the common workflow through task-oriented commands such as `setup`, `add-peer`, `send`, and `receive`. ## 1. Core Security Features @@ -169,6 +170,15 @@ ende init # 4. Test encryption/decryption ``` +**Incremental Step**: +```bash +ende setup +ende add-peer +ende send -t bob +ende receive -i secret.ende -o secret.txt +``` +This keeps the existing trust model while giving new users goal-oriented entrypoints. + --- ### 2.5 GUI Tool - LOW PRIORITY diff --git a/README.md b/README.md index 05aa05b..2b79655 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ The tutorial guides you through: 5. **Decrypt** — automatically decrypts the result from step 4 ## Quickstart +Prefer the task-oriented commands if you're new to Ende: + +```bash +ende setup +ende add-peer +ende send -t +ende receive -i secret.ende -o decrypted.txt +``` + +These commands use the same secure primitives as `key keygen`, `register`, `encrypt`, and `decrypt`, but present them in a workflow that is easier to remember. + 1. Generate local key material: ```bash ./ende key keygen --name alice --export-public --export-dir . @@ -218,6 +229,10 @@ Use `ende doctor` to validate local trust and configuration before troubleshooti The command prints `ok`, `warn`, and `fail` results and exits non-zero when a hard failure is detected. ## Shortcuts +- `ende setup` = create your default local key and print a share token +- `ende add-peer` = `ende register` +- `ende send` = `ende encrypt` +- `ende receive` = `ende decrypt` - `ende enc` = `ende encrypt` - `ende dec` = `ende decrypt` - `ende v` = `ende verify` diff --git a/cmd/ende/crypto_cmd.go b/cmd/ende/crypto_cmd.go index 6a410ae..fb2c057 100644 --- a/cmd/ende/crypto_cmd.go +++ b/cmd/ende/crypto_cmd.go @@ -50,6 +50,7 @@ func newEncryptCommand() *cobra.Command { Short: "Encrypt and sign secret payload", Aliases: []string{ "enc", + "send", }, PreRunE: func(cmd *cobra.Command, args []string) error { if textOut && binaryOut { @@ -175,6 +176,7 @@ func newDecryptCommand() *cobra.Command { Short: "Verify and decrypt envelope", Aliases: []string{ "dec", + "receive", }, RunE: func(cmd *cobra.Command, args []string) error { if !outTemp { diff --git a/cmd/ende/key_cmd.go b/cmd/ende/key_cmd.go index f96b8c9..3258c9e 100644 --- a/cmd/ende/key_cmd.go +++ b/cmd/ende/key_cmd.go @@ -1,7 +1,9 @@ package main import ( + "bufio" "fmt" + "io" "os" "path/filepath" "strings" @@ -33,90 +35,130 @@ func newKeygenCommand() *cobra.Command { "kg", }, RunE: func(cmd *cobra.Command, args []string) error { - if strings.TrimSpace(name) == "" { - return fmt.Errorf("--name is required") - } - diag.Debugf("keygen: start name=%s set_default=%v export_public=%v export_dir=%s", name, setDefault, exportPublic, exportDir) - store, err := keyring.Load() - if err != nil { - return err - } - if _, exists := store.Key(name); exists { - return fmt.Errorf("key %s already exists", name) - } - _, _, keysDir, err := keyring.DefaultPaths() - if err != nil { - return err - } + return runKeygen(name, setDefault, exportPublic, exportDir, exportPrefix, cmd.OutOrStdout()) + }, + } + cmd.Flags().StringVar(&name, "name", "", "key id") + cmd.Flags().BoolVar(&setDefault, "set-default", true, "set generated key as default signer") + cmd.Flags().BoolVar(&exportPublic, "export-public", false, "export public keys to files") + cmd.Flags().StringVar(&exportDir, "export-dir", ".", "directory for exported public key files") + cmd.Flags().StringVar(&exportPrefix, "export-prefix", "", "filename prefix for exported files (defaults to --name)") + return cmd +} - xid, err := age.GenerateX25519Identity() - if err != nil { - return fmt.Errorf("generate age identity: %w", err) - } - signPub, signPriv, err := sign.GenerateKeyPair() - if err != nil { - return err - } +func runKeygen(name string, setDefault bool, exportPublic bool, exportDir, exportPrefix string, out io.Writer) error { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--name is required") + } + diag.Debugf("keygen: start name=%s set_default=%v export_public=%v export_dir=%s", name, setDefault, exportPublic, exportDir) + store, err := keyring.Load() + if err != nil { + return err + } + if _, exists := store.Key(name); exists { + return fmt.Errorf("key %s already exists", name) + } + _, _, keysDir, err := keyring.DefaultPaths() + if err != nil { + return err + } - agePath := filepath.Join(keysDir, name+".agekey") - signPath := filepath.Join(keysDir, name+".signkey") - if err := os.WriteFile(agePath, []byte(xid.String()+"\n"), 0o600); err != nil { - return fmt.Errorf("write age identity: %w", err) - } - if err := os.WriteFile(signPath, []byte(signPriv+"\n"), 0o600); err != nil { - return fmt.Errorf("write signing private key: %w", err) - } + xid, err := age.GenerateX25519Identity() + if err != nil { + return fmt.Errorf("generate age identity: %w", err) + } + signPub, signPriv, err := sign.GenerateKeyPair() + if err != nil { + return err + } - store.AddKey(keyring.KeyEntry{ - ID: name, - AgeIdentity: agePath, - SignPrivate: signPath, - SignPublic: signPub, - }) - // Local keys are always trusted senders for self-verification use cases. - if err := store.AddSender(name, signPub, "local-key", "", true); err != nil { - return err - } - if setDefault { - if err := store.SetDefaultSigner(name); err != nil { - return err + agePath := filepath.Join(keysDir, name+".agekey") + signPath := filepath.Join(keysDir, name+".signkey") + if err := os.WriteFile(agePath, []byte(xid.String()+"\n"), 0o600); err != nil { + return fmt.Errorf("write age identity: %w", err) + } + if err := os.WriteFile(signPath, []byte(signPriv+"\n"), 0o600); err != nil { + return fmt.Errorf("write signing private key: %w", err) + } + + store.AddKey(keyring.KeyEntry{ + ID: name, + AgeIdentity: agePath, + SignPrivate: signPath, + SignPublic: signPub, + }) + if err := store.AddSender(name, signPub, "local-key", "", true); err != nil { + return err + } + if setDefault { + if err := store.SetDefaultSigner(name); err != nil { + return err + } + } + if err := store.Save(); err != nil { + return err + } + + recipientPub := xid.Recipient().String() + shareToken, err := encodeShareToken(name, recipientPub, signPub) + if err != nil { + return err + } + if exportPublic { + prefix := strings.TrimSpace(exportPrefix) + if prefix == "" { + prefix = name + } + if err := os.MkdirAll(exportDir, 0o755); err != nil { + return fmt.Errorf("create export dir: %w", err) + } + recipientOut := filepath.Join(exportDir, prefix+".recipient.pub") + signingOut := filepath.Join(exportDir, prefix+".signing.pub") + if err := os.WriteFile(recipientOut, []byte(recipientPub+"\n"), 0o644); err != nil { + return fmt.Errorf("write recipient export: %w", err) + } + if err := os.WriteFile(signingOut, []byte(signPub+"\n"), 0o644); err != nil { + return fmt.Errorf("write signing export: %w", err) + } + fmt.Fprintf(out, "exported recipient to %s\nexported signing-public to %s\n", recipientOut, signingOut) + } + + diag.Debugf("keygen: completed name=%s", name) + fmt.Fprintf(out, "generated key %s\nrecipient: %s\nsigning-public: %s\nshare: %s\n", name, xid.Recipient().String(), signPub, shareToken) + return nil +} + +func newSetupCommand() *cobra.Command { + var name string + var exportPublic bool + var exportDir string + var exportPrefix string + cmd := &cobra.Command{ + Use: "setup", + Short: "Set up your local key and print a share token for a peer", + Long: "Set up your local key with a task-oriented command that creates your default sender key and prints a share token for peer onboarding.", + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(name) == "" { + fmt.Fprint(cmd.ErrOrStderr(), "your name / key id: ") + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("read setup name: %w", err) } + name = strings.TrimSpace(line) } - if err := store.Save(); err != nil { - return err - } - - recipientPub := xid.Recipient().String() - shareToken, err := encodeShareToken(name, recipientPub, signPub) - if err != nil { + if err := runKeygen(name, true, exportPublic, exportDir, exportPrefix, cmd.OutOrStdout()); err != nil { return err } - if exportPublic { - prefix := strings.TrimSpace(exportPrefix) - if prefix == "" { - prefix = name - } - if err := os.MkdirAll(exportDir, 0o755); err != nil { - return fmt.Errorf("create export dir: %w", err) - } - recipientOut := filepath.Join(exportDir, prefix+".recipient.pub") - signingOut := filepath.Join(exportDir, prefix+".signing.pub") - if err := os.WriteFile(recipientOut, []byte(recipientPub+"\n"), 0o644); err != nil { - return fmt.Errorf("write recipient export: %w", err) - } - if err := os.WriteFile(signingOut, []byte(signPub+"\n"), 0o644); err != nil { - return fmt.Errorf("write signing export: %w", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "exported recipient to %s\nexported signing-public to %s\n", recipientOut, signingOut) - } - - diag.Debugf("keygen: completed name=%s", name) - fmt.Fprintf(cmd.OutOrStdout(), "generated key %s\nrecipient: %s\nsigning-public: %s\nshare: %s\n", name, xid.Recipient().String(), signPub, shareToken) + fmt.Fprintln(cmd.OutOrStdout(), "") + fmt.Fprintln(cmd.OutOrStdout(), "Next steps:") + fmt.Fprintln(cmd.OutOrStdout(), "- Share the `share:` token with your peer.") + fmt.Fprintln(cmd.OutOrStdout(), "- Ask them to run `ende add-peer` or `ende register` with that token.") + fmt.Fprintln(cmd.OutOrStdout(), "- Then send a secret with `ende send -t `.") return nil }, } - cmd.Flags().StringVar(&name, "name", "", "key id") - cmd.Flags().BoolVar(&setDefault, "set-default", true, "set generated key as default signer") + cmd.Flags().StringVar(&name, "name", "", "your local key id") cmd.Flags().BoolVar(&exportPublic, "export-public", false, "export public keys to files") cmd.Flags().StringVar(&exportDir, "export-dir", ".", "directory for exported public key files") cmd.Flags().StringVar(&exportPrefix, "export-prefix", "", "filename prefix for exported files (defaults to --name)") diff --git a/cmd/ende/parties_cmd.go b/cmd/ende/parties_cmd.go index b621254..746daf3 100644 --- a/cmd/ende/parties_cmd.go +++ b/cmd/ende/parties_cmd.go @@ -67,7 +67,7 @@ func newRegisterCommand() *cobra.Command { cmd := &cobra.Command{ Use: "register", Short: "Register recipient and trusted sender in one step", - Aliases: []string{"reg"}, + Aliases: []string{"reg", "add-peer"}, RunE: func(cmd *cobra.Command, args []string) error { store, err := keyring.Load() if err != nil { diff --git a/cmd/ende/root.go b/cmd/ende/root.go index 490ce45..0e28d11 100644 --- a/cmd/ende/root.go +++ b/cmd/ende/root.go @@ -29,7 +29,7 @@ func main() { root.SetVersionTemplate("{{.Use}} version {{.Version}}\ncommit: " + commit + "\nbuilt: " + date + "\n") root.Flags().BoolP("version", "V", false, "print version information") root.PersistentFlags().BoolVar(&debug, "debug", false, "enable diagnostic logs to stderr") - root.AddCommand(newVersionCommand(), newKeyCommand(), newRecipientCommand(), newSenderCommand(), newRegisterCommand(), newUnregisterCommand(), newEncryptCommand(), newDecryptCommand(), newVerifyCommand(), newTutorialCommand(), newDoctorCommand()) + root.AddCommand(newVersionCommand(), newSetupCommand(), newKeyCommand(), newRecipientCommand(), newSenderCommand(), newRegisterCommand(), newUnregisterCommand(), newEncryptCommand(), newDecryptCommand(), newVerifyCommand(), newTutorialCommand(), newDoctorCommand()) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/cmd/ende/ux_cmd_test.go b/cmd/ende/ux_cmd_test.go new file mode 100644 index 0000000..58b49b6 --- /dev/null +++ b/cmd/ende/ux_cmd_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/kuma/ende/internal/keyring" +) + +func TestSetupCommandGeneratesKeyAndShare(t *testing.T) { + configDir := t.TempDir() + t.Setenv("ENDE_CONFIG_DIR", configDir) + + cmd := newSetupCommand() + cmd.SetArgs([]string{"--name", "alice"}) + var out bytes.Buffer + var errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("setup command failed: %v", err) + } + + result := out.String() + for _, want := range []string{ + "generated key alice", + "share: ENDE-PUB-1:", + "Next steps:", + "ende add-peer", + "ende send -t ", + } { + if !strings.Contains(result, want) { + t.Fatalf("expected output to contain %q\nfull output:\n%s", want, result) + } + } + + store, err := keyring.Load() + if err != nil { + t.Fatalf("load keyring: %v", err) + } + if store.DefaultSigner() != "alice" { + t.Fatalf("default signer = %q, want alice", store.DefaultSigner()) + } + if _, ok := store.Key("alice"); !ok { + t.Fatal("expected key alice to exist") + } +} + +func TestTaskOrientedAliasesPresent(t *testing.T) { + if !containsAlias(newEncryptCommand().Aliases, "send") { + t.Fatal("encrypt command should expose send alias") + } + if !containsAlias(newDecryptCommand().Aliases, "receive") { + t.Fatal("decrypt command should expose receive alias") + } + if !containsAlias(newRegisterCommand().Aliases, "add-peer") { + t.Fatal("register command should expose add-peer alias") + } +} + +func containsAlias(aliases []string, want string) bool { + for _, alias := range aliases { + if alias == want { + return true + } + } + return false +}