diff --git a/README.md b/README.md index ba13b13..c4a0899 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ `sneak.berlin/go/x` is a WTFPL-licensed Go module and CLI tool by [@sneak](https://sneak.berlin) that serves as an umbrella publishing space for -small reusable packages, plus `sutil`, a command-line utility that exposes them -from the shell. +small reusable packages, plus `sutil`, a command-line utility holding a few +small tools worth having on every machine. Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that exists only to support it lives in `internal/`. @@ -39,15 +39,6 @@ sutil generate random --count 1073741824 "my seed" > testdata.bin sutil generate password # marble7-sprout2-velvet9 -# A new 24-word BIP39 mnemonic phrase (or --words 12). -sutil generate mnemonic - -# A crypto_box keypair derived from a mnemonic. The mnemonic is read from -# stdin, never from the command line, so it stays out of shell history. -sutil generate mnemonic > mnemonic.txt -sutil cbk derive < mnemonic.txt -sutil cbk derive --public < mnemonic.txt - # Build metadata, including which config file (if any) was loaded. sutil version sutil info @@ -228,12 +219,14 @@ sutil ├── info print version, Go version, module, VCS metadata ├── generate (gen) │ ├── random [seedstr] stream pseudorandom bytes (--count N to stop) -│ ├── password (pw) print a memorable password -│ └── mnemonic print a new BIP39 mnemonic (--words N) -└── cbk - └── derive keypair from a mnemonic on stdin (--public) +│ └── password (pw) print a memorable password ``` +`sutil` is not a command-line front end for the packages in `pkg/`. It holds +only the handful of utilities the author wants available everywhere. `pkg/cbk` +and `pkg/bip39wordlist` are libraries with no CLI surface, and a command is +added because a tool is wanted, not because a package exists. + ### Build, test, and CI The `Makefile` is the single entry point: `make test` (30-second timeout, quiet @@ -275,10 +268,6 @@ make docker # the full CI build `exhaustruct`, and replaces the deprecated `gomodguard` with `gomodguard_v2`. With `default: all`, newer images enable `exhaustruct_v5` and fail every partial struct literal. -- [ ] Add `sutil cbk seal` and `sutil cbk open` so messages can be sealed and - opened from the shell, not just from Go. -- [ ] Let `sutil cbk derive` take the BIP39 passphrase from an environment - variable; the library supports it, the CLI does not expose it yet. ## License diff --git a/cmd/sutil/main.go b/cmd/sutil/main.go index 6825c67..5d5ac2b 100644 --- a/cmd/sutil/main.go +++ b/cmd/sutil/main.go @@ -1,5 +1,5 @@ -// 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. +// Command sutil is sneak's shell utility: a few small tools worth having on +// every machine. Run `sutil --help` for the command list. package main import ( diff --git a/internal/cli/cbk.go b/internal/cli/cbk.go deleted file mode 100644 index 3ee4f2c..0000000 --- a/internal/cli/cbk.go +++ /dev/null @@ -1,82 +0,0 @@ -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 index c168cc4..39f19db 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -37,9 +37,8 @@ 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. + Long: `sutil is a small collection of shell utilities worth having on every +machine: a fast pseudorandom byte stream and a memorable password generator. Before any command runs, sutil loads the optional configuration file named by --config, or else ~/.sneak.yaml (or .json, .toml, ...) if it exists, and @@ -58,7 +57,6 @@ makes environment variables available alongside it.`, newVersionCommand(p), newInfoCommand(a), newGenerateCommand(), - newCBKCommand(), ) return root diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b2f95d3..b4ba2cf 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -13,29 +13,14 @@ import ( "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" -} +const testVersion = "v1.2.3-test" func TestVersionCommand(t *testing.T) { t.Parallel() - output, err := execute(t, "", "version") + output, err := execute(t, "version") require.NoError(t, err) assert.Equal(t, testVersion+"\n", output) } @@ -43,7 +28,7 @@ func TestVersionCommand(t *testing.T) { func TestVersionFlag(t *testing.T) { t.Parallel() - output, err := execute(t, "", "--version") + output, err := execute(t, "--version") require.NoError(t, err) assert.Contains(t, output, testVersion) } @@ -51,7 +36,7 @@ func TestVersionFlag(t *testing.T) { func TestInfoCommand(t *testing.T) { t.Parallel() - output, err := execute(t, "", "info") + output, err := execute(t, "info") require.NoError(t, err) assert.Contains(t, output, "version: "+testVersion+"\n") assert.Contains(t, output, "go: go") @@ -68,7 +53,7 @@ func TestConfigFileDiscoveredInConfigDir(t *testing.T) { params := cli.Params{Version: testVersion, ConfigDir: configDir} - output, err := executeWithParams(t, params, "", "info") + output, err := executeWithParams(t, params, "info") require.NoError(t, err) assert.Contains(t, output, "Using config file: "+path+"\n") assert.Contains(t, output, "config: "+path+"\n") @@ -80,7 +65,7 @@ func TestConfigFlagLoadsNamedFile(t *testing.T) { path := filepath.Join(t.TempDir(), "elsewhere.yaml") require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600)) - output, err := execute(t, "", "--config", path, "info") + output, err := execute(t, "--config", path, "info") require.NoError(t, err) assert.Contains(t, output, "Using config file: "+path+"\n") assert.Contains(t, output, "config: "+path+"\n") @@ -91,7 +76,7 @@ func TestConfigFlagRejectsMissingFile(t *testing.T) { path := filepath.Join(t.TempDir(), "missing.yaml") - _, err := execute(t, "", "--config", path, "version") + _, err := execute(t, "--config", path, "version") require.Error(t, err) } @@ -104,22 +89,22 @@ func TestConfigRejectsMalformedFile(t *testing.T) { params := cli.Params{Version: testVersion, ConfigDir: configDir} - _, err := executeWithParams(t, params, "", "version") + _, err := executeWithParams(t, params, "version") require.Error(t, err) } func TestGenerateRandomSeededIsDeterministic(t *testing.T) { t.Parallel() - first, err := execute(t, "", "generate", "random", "--count", "4096", "seed") + 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") + 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") + other, err := execute(t, "gen", "random", "-c", "4096", "other seed") require.NoError(t, err) assert.NotEqual(t, first, other) } @@ -127,11 +112,11 @@ func TestGenerateRandomSeededIsDeterministic(t *testing.T) { func TestGenerateRandomUnseeded(t *testing.T) { t.Parallel() - first, err := execute(t, "", "generate", "random", "--count", "1024") + 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") + second, err := execute(t, "generate", "random", "--count", "1024") require.NoError(t, err) assert.NotEqual(t, first, second) } @@ -139,28 +124,10 @@ func TestGenerateRandomUnseeded(t *testing.T) { func TestGenerateRandomRejectsExtraArguments(t *testing.T) { t.Parallel() - _, err := execute(t, "", "generate", "random", "one", "two") + _, 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() @@ -171,7 +138,7 @@ func TestGeneratePassword(t *testing.T) { require.NoError(t, err) - output, err := execute(t, "", "generate", "password") + output, err := execute(t, "generate", "password") require.NoError(t, err) password := strings.TrimSuffix(output, "\n") @@ -181,48 +148,27 @@ func TestGeneratePassword(t *testing.T) { assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password) } -func TestCBKDerive(t *testing.T) { +func TestUnknownCommandFails(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) + _, err := execute(t, "cbk") + require.Error(t, err) } -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, looking for config -// in an empty temporary directory, and returns everything it wrote to -// stdout and stderr. -func execute(t *testing.T, stdin string, args ...string) (string, error) { +// execute runs sutil with the given arguments, looking for config in an +// empty temporary directory, and returns everything it wrote to stdout and +// stderr. +func execute(t *testing.T, args ...string) (string, error) { t.Helper() params := cli.Params{Version: testVersion, ConfigDir: t.TempDir()} - return executeWithParams(t, params, stdin, args...) + return executeWithParams(t, params, args...) } // executeWithParams is execute with explicit Params. func executeWithParams( - t *testing.T, params cli.Params, stdin string, args ...string, + t *testing.T, params cli.Params, args ...string, ) (string, error) { t.Helper() @@ -232,7 +178,6 @@ func executeWithParams( root.SetOut(&output) root.SetErr(&output) - root.SetIn(strings.NewReader(stdin)) root.SetArgs(args) err := root.Execute() diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 643d159..5e60588 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -8,7 +8,6 @@ import ( "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 @@ -21,13 +20,12 @@ func newGenerateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "generate", Aliases: []string{"gen"}, - Short: "Generate random bytes, passwords, and mnemonics", + Short: "Generate random bytes and passwords", } cmd.AddCommand( newGenerateRandomCommand(), newGeneratePasswordCommand(), - newGenerateMnemonicCommand(), ) return cmd @@ -107,36 +105,6 @@ no usable word list.`, } } -// 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 {