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:
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+24
-79
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user