Skip to content
Open
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
10 changes: 10 additions & 0 deletions .kiro/feature-recommendations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <peer>
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 .
Expand Down Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions cmd/ende/crypto_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
190 changes: 116 additions & 74 deletions cmd/ende/key_cmd.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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 {
Comment thread
YoungJinJung marked this conversation as resolved.
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 <peer>`.")
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)")
Expand Down
2 changes: 1 addition & 1 deletion cmd/ende/parties_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/ende/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions cmd/ende/ux_cmd_test.go
Original file line number Diff line number Diff line change
@@ -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 <peer>",
} {
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
}
Loading