From f53b6aad4261576def805d4ea7595e6a138ec958 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 31 Oct 2026 10:03:06 -0700 Subject: [PATCH] Add sutil CLI in cmd/sutil with internal/cli command tree --- cmd/sutil/main.go | 20 +++++ internal/cli/cbk.go | 82 ++++++++++++++++++ internal/cli/cli.go | 39 +++++++++ internal/cli/cli_test.go | 177 +++++++++++++++++++++++++++++++++++++++ internal/cli/generate.go | 157 ++++++++++++++++++++++++++++++++++ internal/cli/info.go | 54 ++++++++++++ internal/cli/version.go | 22 +++++ 7 files changed, 551 insertions(+) create mode 100644 cmd/sutil/main.go create mode 100644 internal/cli/cbk.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/generate.go create mode 100644 internal/cli/info.go create mode 100644 internal/cli/version.go diff --git a/cmd/sutil/main.go b/cmd/sutil/main.go new file mode 100644 index 0000000..185e43c --- /dev/null +++ b/cmd/sutil/main.go @@ -0,0 +1,20 @@ +// Command sutil is sneak's shell utility: small tools backed by the +// packages in sneak.berlin/go/x. Run `sutil --help` for the command list. +package main + +import ( + "os" + + "sneak.berlin/go/x/internal/cli" +) + +// version is the build version. The Makefile and Dockerfile set it at link +// time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + err := cli.New(cli.Params{Version: version}).Execute() + if err != nil { + os.Exit(1) + } +} diff --git a/internal/cli/cbk.go b/internal/cli/cbk.go new file mode 100644 index 0000000..3ee4f2c --- /dev/null +++ b/internal/cli/cbk.go @@ -0,0 +1,82 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "sneak.berlin/go/x/pkg/cbk" +) + +// maxMnemonicInputBytes bounds how much of stdin `cbk derive` will read; a +// 24-word mnemonic is well under 300 bytes. +const maxMnemonicInputBytes = 64 << 10 + +// newCBKCommand returns `sutil cbk`, the parent of the crypto_box +// keypair commands. +func newCBKCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cbk", + Short: "crypto_box keypairs derived from BIP39 mnemonics", + } + + cmd.AddCommand(newCBKDeriveCommand()) + + return cmd +} + +// newCBKDeriveCommand returns `sutil cbk derive`. +func newCBKDeriveCommand() *cobra.Command { + var publicOnly bool + + cmd := &cobra.Command{ + Use: "derive", + Short: "Derive a crypto_box keypair from a mnemonic read on stdin", + Long: `Reads a BIP39 mnemonic from stdin, in any letter case and with any +whitespace between words, and prints the crypto_box keypair derived from it +as hex. The same mnemonic always yields the same keypair. + +The mnemonic is read from stdin rather than taken as an argument so that it +does not end up in shell history or process listings: + + sutil generate mnemonic > mnemonic.txt + sutil cbk derive < mnemonic.txt`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + input := io.LimitReader(cmd.InOrStdin(), maxMnemonicInputBytes) + + mnemonic, err := io.ReadAll(input) + if err != nil { + return fmt.Errorf("reading mnemonic from stdin: %w", err) + } + + keypair, err := cbk.New(cbk.Params{Mnemonic: string(mnemonic)}) + if err != nil { + return err + } + + return writeKeypair(cmd.OutOrStdout(), keypair, publicOnly) + }, + } + + cmd.Flags().BoolVar(&publicOnly, "public", false, "print only the public key") + + return cmd +} + +// writeKeypair prints the keypair as labeled hex lines, or just the bare +// public key when publicOnly is set. +func writeKeypair(w io.Writer, keypair cbk.CryptoBoxKeypair, publicOnly bool) error { + if publicOnly { + _, err := fmt.Fprintln(w, keypair.PublicKeyHex()) + + return err + } + + _, err := fmt.Fprintf( + w, "public: %s\nprivate: %s\n", keypair.PublicKeyHex(), keypair.PrivateKeyHex(), + ) + + return err +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..f7bf717 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,39 @@ +// Package cli implements the sutil command tree. +// +// The tree is built by New rather than assembled in init functions, so +// the package holds no global state and tests can construct it repeatedly +// with captured input and output. +package cli + +import ( + "github.com/spf13/cobra" +) + +// Params configures New. +type Params struct { + // Version is the version string embedded at build time. It is shown + // by `sutil version`, `sutil --version`, and `sutil info`. + Version string +} + +// New builds the root sutil command with every subcommand attached. +func New(p Params) *cobra.Command { + root := &cobra.Command{ + Use: "sutil", + Short: "sneak's shell utility", + Long: `sutil is a small collection of shell utilities built on the packages in +sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords, +BIP39 mnemonics, and crypto_box keypairs derived from them.`, + Version: p.Version, + SilenceUsage: true, + } + + root.AddCommand( + newVersionCommand(p), + newInfoCommand(p), + newGenerateCommand(), + newCBKCommand(), + ) + + return root +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..c900fbc --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,177 @@ +package cli_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/x/internal/cli" + "sneak.berlin/go/x/internal/pwgen" + "sneak.berlin/go/x/pkg/bip39wordlist" + "sneak.berlin/go/x/pkg/cbk" +) + +const ( + testVersion = "v1.2.3-test" + + // vectorRepeats is how many times "abandon" appears in the standard + // BIP39 test vector for all-zero 128-bit entropy, which ends with + // "about". + vectorRepeats = bip39wordlist.MinWordCount - 1 +) + +// vectorMnemonic returns the standard BIP39 test vector for all-zero +// 128-bit entropy. +func vectorMnemonic() string { + return strings.Repeat("abandon ", vectorRepeats) + "about" +} + +func TestVersionCommand(t *testing.T) { + t.Parallel() + + output, err := execute(t, "", "version") + require.NoError(t, err) + assert.Equal(t, testVersion+"\n", output) +} + +func TestVersionFlag(t *testing.T) { + t.Parallel() + + output, err := execute(t, "", "--version") + require.NoError(t, err) + assert.Contains(t, output, testVersion) +} + +func TestInfoCommand(t *testing.T) { + t.Parallel() + + output, err := execute(t, "", "info") + require.NoError(t, err) + assert.Contains(t, output, "version: "+testVersion+"\n") + assert.Contains(t, output, "go: go") + assert.Contains(t, output, "module: sneak.berlin/go/x\n") +} + +func TestGenerateRandomSeededIsDeterministic(t *testing.T) { + t.Parallel() + + first, err := execute(t, "", "generate", "random", "--count", "4096", "seed") + require.NoError(t, err) + assert.Len(t, first, 4096) + + second, err := execute(t, "", "generate", "random", "--count", "4096", "seed") + require.NoError(t, err) + assert.Equal(t, first, second) + + other, err := execute(t, "", "gen", "random", "-c", "4096", "other seed") + require.NoError(t, err) + assert.NotEqual(t, first, other) +} + +func TestGenerateRandomUnseeded(t *testing.T) { + t.Parallel() + + first, err := execute(t, "", "generate", "random", "--count", "1024") + require.NoError(t, err) + assert.Len(t, first, 1024) + + second, err := execute(t, "", "generate", "random", "--count", "1024") + require.NoError(t, err) + assert.NotEqual(t, first, second) +} + +func TestGenerateRandomRejectsExtraArguments(t *testing.T) { + t.Parallel() + + _, err := execute(t, "", "generate", "random", "one", "two") + require.Error(t, err) +} + +func TestGenerateMnemonic(t *testing.T) { + t.Parallel() + + output, err := execute(t, "", "generate", "mnemonic") + require.NoError(t, err) + + mnemonic := strings.TrimSuffix(output, "\n") + require.NoError(t, bip39wordlist.ValidateWordlist(mnemonic)) + assert.Len(t, strings.Fields(mnemonic), bip39wordlist.MaxWordCount) + + output, err = execute(t, "", "generate", "mnemonic", "--words", "12") + require.NoError(t, err) + assert.Len(t, strings.Fields(output), bip39wordlist.MinWordCount) + + _, err = execute(t, "", "generate", "mnemonic", "--words", "13") + require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) +} + +func TestGeneratePassword(t *testing.T) { + t.Parallel() + + _, err := pwgen.New(pwgen.Params{Words: nil, MinWordLength: 0, MaxWordLength: 0}) + if errors.Is(err, pwgen.ErrNoUsableWords) { + t.Skip("this system has no word list") + } + + require.NoError(t, err) + + output, err := execute(t, "", "generate", "password") + require.NoError(t, err) + + password := strings.TrimSuffix(output, "\n") + assert.NotContains(t, password, "\n") + assert.GreaterOrEqual(t, len(password), 3*(pwgen.DefaultMinWordLength+1)+2) + assert.True(t, strings.ContainsAny(password, "0123456789"), password) + assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password) +} + +func TestCBKDerive(t *testing.T) { + t.Parallel() + + expected, err := cbk.FromMnemonic(vectorMnemonic()) + require.NoError(t, err) + + stdin := " " + strings.ToUpper(vectorMnemonic()) + "\n" + + output, err := execute(t, stdin, "cbk", "derive") + require.NoError(t, err) + assert.Equal(t, + "public: "+expected.PublicKeyHex()+"\n"+ + "private: "+expected.PrivateKeyHex()+"\n", + output, + ) + + output, err = execute(t, vectorMnemonic(), "cbk", "derive", "--public") + require.NoError(t, err) + assert.Equal(t, expected.PublicKeyHex()+"\n", output) +} + +func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) { + t.Parallel() + + _, err := execute(t, "not a mnemonic\n", "cbk", "derive") + require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) +} + +// execute runs sutil with the given stdin and arguments and returns +// everything it wrote to stdout and stderr. +func execute(t *testing.T, stdin string, args ...string) (string, error) { + t.Helper() + + root := cli.New(cli.Params{Version: testVersion}) + + var output bytes.Buffer + + root.SetOut(&output) + root.SetErr(&output) + root.SetIn(strings.NewReader(stdin)) + root.SetArgs(args) + + err := root.Execute() + + return output.String(), err +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go new file mode 100644 index 0000000..643d159 --- /dev/null +++ b/internal/cli/generate.go @@ -0,0 +1,157 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "sneak.berlin/go/x/internal/pwgen" + "sneak.berlin/go/x/internal/randstream" + "sneak.berlin/go/x/pkg/bip39wordlist" +) + +// streamChunkSize is the write size used by `generate random`. Large +// writes keep the per-syscall overhead negligible. +const streamChunkSize = 1 << 20 + +// newGenerateCommand returns `sutil generate`, the parent of everything +// that produces random output. +func newGenerateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Aliases: []string{"gen"}, + Short: "Generate random bytes, passwords, and mnemonics", + } + + cmd.AddCommand( + newGenerateRandomCommand(), + newGeneratePasswordCommand(), + newGenerateMnemonicCommand(), + ) + + return cmd +} + +// newGenerateRandomCommand returns `sutil generate random [seedstr]`. +func newGenerateRandomCommand() *cobra.Command { + var byteCount int64 + + cmd := &cobra.Command{ + Use: "random [seedstr]", + Short: "Write a fast stream of pseudorandom bytes to stdout", + Long: `Writes pseudorandom bytes to stdout until stopped, or until --count +bytes have been written. + +With seedstr the stream is deterministic: the same seed always produces +the same bytes. Without it the stream is keyed from the operating +system's secure random source and is different every run. + +This exists because /dev/urandom is slow.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + seed := "" + if len(args) == 1 { + seed = args[0] + } + + stream, err := randstream.New(randstream.Params{Seed: seed}) + if err != nil { + return err + } + + return streamBytes(cmd.OutOrStdout(), stream, byteCount) + }, + } + + cmd.Flags().Int64VarP( + &byteCount, "count", "c", 0, "stop after this many bytes (0 = never)", + ) + + return cmd +} + +// newGeneratePasswordCommand returns `sutil generate password`. +func newGeneratePasswordCommand() *cobra.Command { + return &cobra.Command{ + Use: "password", + Aliases: []string{"pw"}, + Short: "Print a memorable password built from system dictionary words", + Long: `Prints a password of the form + + + +for example marble7-sprout2-velvet9. The words come from the system word +list (/usr/share/dict/words), the delimiter is chosen at random from + + ` + pwgen.Delimiters + ` + +and is the same in both positions. The command panics if the system has +no usable word list.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + generator, err := pwgen.New(pwgen.Params{}) + if err != nil { + panic("sutil: cannot load the system word list: " + err.Error()) + } + + password, err := generator.Generate() + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), password) + + return err + }, + } +} + +// newGenerateMnemonicCommand returns `sutil generate mnemonic`. +func newGenerateMnemonicCommand() *cobra.Command { + var wordCount int + + cmd := &cobra.Command{ + Use: "mnemonic", + Short: "Print a new random BIP39 mnemonic phrase", + Long: `Prints a new BIP39 mnemonic phrase generated from crypto/rand. The +default of 24 words encodes 256 bits of entropy; 12 words encodes 128.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount) + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), mnemonic) + + return err + }, + } + + cmd.Flags().IntVarP( + &wordCount, "words", "w", bip39wordlist.MaxWordCount, + "number of words: 12, 15, 18, 21, or 24", + ) + + return cmd +} + +// streamBytes copies limit bytes (or everything, when limit is zero) from +// src to dst in streamChunkSize chunks. +func streamBytes(dst io.Writer, src io.Reader, limit int64) error { + if limit > 0 { + src = io.LimitReader(src, limit) + } + + // Wrapping dst hides any ReadFrom method it has, so io.CopyBuffer + // cannot bypass the large buffer. + _, err := io.CopyBuffer(writerOnly{dst}, src, make([]byte, streamChunkSize)) + + return err +} + +// writerOnly exposes only the Write method of the wrapped writer. +type writerOnly struct { + io.Writer +} diff --git a/internal/cli/info.go b/internal/cli/info.go new file mode 100644 index 0000000..f95ac29 --- /dev/null +++ b/internal/cli/info.go @@ -0,0 +1,54 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "runtime/debug" + "strings" + + "github.com/spf13/cobra" +) + +// ErrNoBuildInfo is returned by `sutil info` when the binary carries no +// Go build information, which only happens for binaries not built with +// module support. +var ErrNoBuildInfo = errors.New("sutil: no build information in this binary") + +// newInfoCommand returns `sutil info`, which prints the build version, the +// Go toolchain version, the module path, and the VCS metadata recorded by +// the Go toolchain at build time. +func newInfoCommand(p Params) *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Print build information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return writeBuildInfo(cmd.OutOrStdout(), p.Version) + }, + } +} + +// writeBuildInfo writes one "key: value" line per fact to w. +func writeBuildInfo(w io.Writer, version string) error { + info, ok := debug.ReadBuildInfo() + if !ok { + return ErrNoBuildInfo + } + + lines := []string{ + "version: " + version, + "go: " + info.GoVersion, + "module: " + info.Main.Path, + } + + for _, setting := range info.Settings { + if strings.HasPrefix(setting.Key, "vcs.") { + lines = append(lines, setting.Key+": "+setting.Value) + } + } + + _, err := fmt.Fprintln(w, strings.Join(lines, "\n")) + + return err +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..1cf3bce --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// newVersionCommand returns `sutil version`, which prints the build +// version on its own line. +func newVersionCommand(p Params) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the sutil version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), p.Version) + + return err + }, + } +}