Remove cbk and mnemonic commands from sutil

sutil holds only the utilities wanted on every machine; it is not a
command-line front end for the packages in pkg/. cbk and bip39wordlist
remain libraries with no CLI surface. The README states the boundary.
This commit is contained in:
2026-10-31 10:16:10 -07:00
parent 9def7ba586
commit 5932cc12ee
6 changed files with 37 additions and 219 deletions
+8 -19
View File
@@ -4,8 +4,8 @@
`sneak.berlin/go/x` is a WTFPL-licensed Go module and CLI tool by `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 [@sneak](https://sneak.berlin) that serves as an umbrella publishing space for
small reusable packages, plus `sutil`, a command-line utility that exposes them small reusable packages, plus `sutil`, a command-line utility holding a few
from the shell. small tools worth having on every machine.
Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that
exists only to support it lives in `internal/`. exists only to support it lives in `internal/`.
@@ -39,15 +39,6 @@ sutil generate random --count 1073741824 "my seed" > testdata.bin
sutil generate password sutil generate password
# marble7-sprout2-velvet9 # 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. # Build metadata, including which config file (if any) was loaded.
sutil version sutil version
sutil info sutil info
@@ -228,12 +219,14 @@ sutil
├── info print version, Go version, module, VCS metadata ├── info print version, Go version, module, VCS metadata
├── generate (gen) ├── generate (gen)
│ ├── random [seedstr] stream pseudorandom bytes (--count N to stop) │ ├── random [seedstr] stream pseudorandom bytes (--count N to stop)
│ ├── password (pw) print a memorable password │ └── password (pw) print a memorable password
│ └── mnemonic print a new BIP39 mnemonic (--words N)
└── cbk
└── derive keypair from a mnemonic on stdin (--public)
``` ```
`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 ### Build, test, and CI
The `Makefile` is the single entry point: `make test` (30-second timeout, quiet 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 `exhaustruct`, and replaces the deprecated `gomodguard` with
`gomodguard_v2`. With `default: all`, newer images enable `exhaustruct_v5` `gomodguard_v2`. With `default: all`, newer images enable `exhaustruct_v5`
and fail every partial struct literal. 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 ## License
+2 -2
View File
@@ -1,5 +1,5 @@
// Command sutil is sneak's shell utility: small tools backed by the // Command sutil is sneak's shell utility: a few small tools worth having on
// packages in sneak.berlin/go/x. Run `sutil --help` for the command list. // every machine. Run `sutil --help` for the command list.
package main package main
import ( import (
-82
View File
@@ -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
}
+2 -4
View File
@@ -37,9 +37,8 @@ func New(p Params) *cobra.Command {
root := &cobra.Command{ root := &cobra.Command{
Use: "sutil", Use: "sutil",
Short: "sneak's shell utility", Short: "sneak's shell utility",
Long: `sutil is a small collection of shell utilities built on the packages in Long: `sutil is a small collection of shell utilities worth having on every
sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords, machine: a fast pseudorandom byte stream and a memorable password generator.
BIP39 mnemonics, and crypto_box keypairs derived from them.
Before any command runs, sutil loads the optional configuration file named Before any command runs, sutil loads the optional configuration file named
by --config, or else ~/.sneak.yaml (or .json, .toml, ...) if it exists, and 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), newVersionCommand(p),
newInfoCommand(a), newInfoCommand(a),
newGenerateCommand(), newGenerateCommand(),
newCBKCommand(),
) )
return root return root
+24 -79
View File
@@ -13,29 +13,14 @@ import (
"sneak.berlin/go/x/internal/cli" "sneak.berlin/go/x/internal/cli"
"sneak.berlin/go/x/internal/pwgen" "sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk"
) )
const ( const testVersion = "v1.2.3-test"
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) { func TestVersionCommand(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "version") output, err := execute(t, "version")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testVersion+"\n", output) assert.Equal(t, testVersion+"\n", output)
} }
@@ -43,7 +28,7 @@ func TestVersionCommand(t *testing.T) {
func TestVersionFlag(t *testing.T) { func TestVersionFlag(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "--version") output, err := execute(t, "--version")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, output, testVersion) assert.Contains(t, output, testVersion)
} }
@@ -51,7 +36,7 @@ func TestVersionFlag(t *testing.T) {
func TestInfoCommand(t *testing.T) { func TestInfoCommand(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "info") output, err := execute(t, "info")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, output, "version: "+testVersion+"\n") assert.Contains(t, output, "version: "+testVersion+"\n")
assert.Contains(t, output, "go: go") assert.Contains(t, output, "go: go")
@@ -68,7 +53,7 @@ func TestConfigFileDiscoveredInConfigDir(t *testing.T) {
params := cli.Params{Version: testVersion, ConfigDir: configDir} params := cli.Params{Version: testVersion, ConfigDir: configDir}
output, err := executeWithParams(t, params, "", "info") output, err := executeWithParams(t, params, "info")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, output, "Using config file: "+path+"\n") assert.Contains(t, output, "Using config file: "+path+"\n")
assert.Contains(t, output, "config: "+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") path := filepath.Join(t.TempDir(), "elsewhere.yaml")
require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600)) 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) require.NoError(t, err)
assert.Contains(t, output, "Using config file: "+path+"\n") assert.Contains(t, output, "Using config file: "+path+"\n")
assert.Contains(t, output, "config: "+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") path := filepath.Join(t.TempDir(), "missing.yaml")
_, err := execute(t, "", "--config", path, "version") _, err := execute(t, "--config", path, "version")
require.Error(t, err) require.Error(t, err)
} }
@@ -104,22 +89,22 @@ func TestConfigRejectsMalformedFile(t *testing.T) {
params := cli.Params{Version: testVersion, ConfigDir: configDir} params := cli.Params{Version: testVersion, ConfigDir: configDir}
_, err := executeWithParams(t, params, "", "version") _, err := executeWithParams(t, params, "version")
require.Error(t, err) require.Error(t, err)
} }
func TestGenerateRandomSeededIsDeterministic(t *testing.T) { func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
t.Parallel() t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "4096", "seed") first, err := execute(t, "generate", "random", "--count", "4096", "seed")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, first, 4096) 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) require.NoError(t, err)
assert.Equal(t, first, second) 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) require.NoError(t, err)
assert.NotEqual(t, first, other) assert.NotEqual(t, first, other)
} }
@@ -127,11 +112,11 @@ func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
func TestGenerateRandomUnseeded(t *testing.T) { func TestGenerateRandomUnseeded(t *testing.T) {
t.Parallel() t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "1024") first, err := execute(t, "generate", "random", "--count", "1024")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, first, 1024) 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) require.NoError(t, err)
assert.NotEqual(t, first, second) assert.NotEqual(t, first, second)
} }
@@ -139,28 +124,10 @@ func TestGenerateRandomUnseeded(t *testing.T) {
func TestGenerateRandomRejectsExtraArguments(t *testing.T) { func TestGenerateRandomRejectsExtraArguments(t *testing.T) {
t.Parallel() t.Parallel()
_, err := execute(t, "", "generate", "random", "one", "two") _, err := execute(t, "generate", "random", "one", "two")
require.Error(t, err) 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) { func TestGeneratePassword(t *testing.T) {
t.Parallel() t.Parallel()
@@ -171,7 +138,7 @@ func TestGeneratePassword(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
output, err := execute(t, "", "generate", "password") output, err := execute(t, "generate", "password")
require.NoError(t, err) require.NoError(t, err)
password := strings.TrimSuffix(output, "\n") password := strings.TrimSuffix(output, "\n")
@@ -181,48 +148,27 @@ func TestGeneratePassword(t *testing.T) {
assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password) assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password)
} }
func TestCBKDerive(t *testing.T) { func TestUnknownCommandFails(t *testing.T) {
t.Parallel() t.Parallel()
expected, err := cbk.FromMnemonic(vectorMnemonic()) _, err := execute(t, "cbk")
require.NoError(t, err) require.Error(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) { // execute runs sutil with the given arguments, looking for config in an
t.Parallel() // empty temporary directory, and returns everything it wrote to stdout and
// stderr.
_, err := execute(t, "not a mnemonic\n", "cbk", "derive") func execute(t *testing.T, args ...string) (string, error) {
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) {
t.Helper() t.Helper()
params := cli.Params{Version: testVersion, ConfigDir: t.TempDir()} 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. // executeWithParams is execute with explicit Params.
func executeWithParams( func executeWithParams(
t *testing.T, params cli.Params, stdin string, args ...string, t *testing.T, params cli.Params, args ...string,
) (string, error) { ) (string, error) {
t.Helper() t.Helper()
@@ -232,7 +178,6 @@ func executeWithParams(
root.SetOut(&output) root.SetOut(&output)
root.SetErr(&output) root.SetErr(&output)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args) root.SetArgs(args)
err := root.Execute() err := root.Execute()
+1 -33
View File
@@ -8,7 +8,6 @@ import (
"sneak.berlin/go/x/internal/pwgen" "sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/internal/randstream" "sneak.berlin/go/x/internal/randstream"
"sneak.berlin/go/x/pkg/bip39wordlist"
) )
// streamChunkSize is the write size used by `generate random`. Large // streamChunkSize is the write size used by `generate random`. Large
@@ -21,13 +20,12 @@ func newGenerateCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "generate", Use: "generate",
Aliases: []string{"gen"}, Aliases: []string{"gen"},
Short: "Generate random bytes, passwords, and mnemonics", Short: "Generate random bytes and passwords",
} }
cmd.AddCommand( cmd.AddCommand(
newGenerateRandomCommand(), newGenerateRandomCommand(),
newGeneratePasswordCommand(), newGeneratePasswordCommand(),
newGenerateMnemonicCommand(),
) )
return cmd 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 // streamBytes copies limit bytes (or everything, when limit is zero) from
// src to dst in streamChunkSize chunks. // src to dst in streamChunkSize chunks.
func streamBytes(dst io.Writer, src io.Reader, limit int64) error { func streamBytes(dst io.Writer, src io.Reader, limit int64) error {