Skeleton, mnemonic input, derivation, and the ssh pub and priv commands (closes #1)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
The module, the script entrypoints and Makefile, Docker-only linting, the mnemonic sources in the specified order with their refusals, the BIP-85 derivation, and keyfunc ssh pub and priv with the README test vectors as tests. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #5.
This commit is contained in:
117
internal/mnemonic/mnemonic.go
Normal file
117
internal/mnemonic/mnemonic.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package mnemonic finds the mnemonic the keys are derived from.
|
||||
package mnemonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
// CommandVariable holds a shell command whose output is the
|
||||
// mnemonic.
|
||||
CommandVariable = "KEYFUNC_MNEMONIC_COMMAND"
|
||||
|
||||
// Variable holds the mnemonic itself.
|
||||
Variable = "KEYFUNC_MNEMONIC"
|
||||
)
|
||||
|
||||
// ErrMissing is returned when there is nowhere left to look and
|
||||
// standard input is not a terminal, so there is nobody to ask.
|
||||
var ErrMissing = errors.New(
|
||||
"no mnemonic given and standard input is not a terminal",
|
||||
)
|
||||
|
||||
// ErrChecksum is returned for a mnemonic that fails the BIP-39
|
||||
// checksum.
|
||||
var ErrChecksum = errors.New("the mnemonic fails its BIP-39 checksum")
|
||||
|
||||
// Read returns the mnemonic. The command given on the command line is
|
||||
// used first; then the command in KEYFUNC_MNEMONIC_COMMAND; then the
|
||||
// mnemonic in KEYFUNC_MNEMONIC; then a prompt on the terminal with
|
||||
// echo turned off. The first source that has one wins.
|
||||
func Read(ctx context.Context, command string) (string, error) {
|
||||
if command == "" {
|
||||
command = os.Getenv(CommandVariable)
|
||||
}
|
||||
|
||||
if command != "" {
|
||||
words, err := run(ctx, command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return checked(words)
|
||||
}
|
||||
|
||||
if words := os.Getenv(Variable); words != "" {
|
||||
return checked(words)
|
||||
}
|
||||
|
||||
return ask()
|
||||
}
|
||||
|
||||
// run executes the command with sh and returns its standard output.
|
||||
// If the command fails, its standard error becomes part of the error.
|
||||
func run(ctx context.Context, command string) (string, error) {
|
||||
//nolint:gosec // running the user's own command is the point
|
||||
shell := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
|
||||
var complaint bytes.Buffer
|
||||
|
||||
shell.Stderr = &complaint
|
||||
|
||||
out, err := shell.Output()
|
||||
if err != nil {
|
||||
said := strings.TrimSpace(complaint.String())
|
||||
if said == "" {
|
||||
return "", fmt.Errorf("the mnemonic command failed: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(
|
||||
"the mnemonic command failed: %w: %s", err, said,
|
||||
)
|
||||
}
|
||||
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ask prompts on the terminal with echo turned off.
|
||||
func ask() (string, error) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
if !term.IsTerminal(fd) {
|
||||
return "", ErrMissing
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, "mnemonic: ")
|
||||
|
||||
typed, err := term.ReadPassword(fd)
|
||||
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return checked(string(typed))
|
||||
}
|
||||
|
||||
// checked drops the surrounding whitespace and refuses a mnemonic that
|
||||
// does not pass the BIP-39 checksum.
|
||||
func checked(words string) (string, error) {
|
||||
words = strings.TrimSpace(words)
|
||||
|
||||
if !bip39.IsMnemonicValid(words) {
|
||||
return "", ErrChecksum
|
||||
}
|
||||
|
||||
return words, nil
|
||||
}
|
||||
87
internal/mnemonic/mnemonic_test.go
Normal file
87
internal/mnemonic/mnemonic_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package mnemonic_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Two more mnemonics that pass the checksum, so a test can tell which
|
||||
// source an answer came from.
|
||||
const (
|
||||
fromCommandVariable = "legal winner thank year wave sausage worth " +
|
||||
"useful legal winner thank yellow"
|
||||
fromVariable = "letter advice cage absurd amount doctor acoustic " +
|
||||
"avoid letter advice cage above"
|
||||
)
|
||||
|
||||
// example returns the mnemonic every BIP-39 document uses to show its
|
||||
// test vectors: eleven abandons and about.
|
||||
func example() string {
|
||||
return strings.Repeat("abandon ", 11) + "about"
|
||||
}
|
||||
|
||||
// broken returns a mnemonic whose last word does not match the rest.
|
||||
func broken() string {
|
||||
return strings.TrimSpace(strings.Repeat("abandon ", 12))
|
||||
}
|
||||
|
||||
// prints builds a shell command that writes the given words padded
|
||||
// with spaces, so the test also shows that the padding is dropped.
|
||||
func prints(words string) string {
|
||||
return "printf ' %s \\n' '" + words + "'"
|
||||
}
|
||||
|
||||
func TestTheCommandOnTheCommandLineWins(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable))
|
||||
t.Setenv(mnemonic.Variable, fromVariable)
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), prints(example()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, example(), words)
|
||||
}
|
||||
|
||||
func TestTheCommandInTheEnvironmentComesNext(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, prints(fromCommandVariable))
|
||||
t.Setenv(mnemonic.Variable, fromVariable)
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fromCommandVariable, words)
|
||||
}
|
||||
|
||||
func TestTheMnemonicInTheEnvironmentComesLast(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, " "+fromVariable+" ")
|
||||
|
||||
words, err := mnemonic.Read(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fromVariable, words)
|
||||
}
|
||||
|
||||
func TestAFailingCommandSaysWhatWentWrong(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "echo nothing here >&2; exit 3")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nothing here")
|
||||
}
|
||||
|
||||
func TestABadChecksumIsRefused(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, broken())
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "")
|
||||
require.ErrorIs(t, err, mnemonic.ErrChecksum)
|
||||
}
|
||||
|
||||
func TestNothingToReadAndNobodyToAsk(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
|
||||
_, err := mnemonic.Read(t.Context(), "")
|
||||
require.ErrorIs(t, err, mnemonic.ErrMissing)
|
||||
}
|
||||
Reference in New Issue
Block a user