Add sutil CLI in cmd/sutil with internal/cli command tree
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<word><digit><delim><word><digit><delim><word><digit>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user