Files
keyfunc/internal/mnemonic/mnemonic.go
T
clawbot d3ca2e0d57
check / check (push) Waiting to run
End the mnemonic prompt at end of input (closes #78)
term.ReadPassword reads the terminal with a raw read that returns 0
bytes at the end of input and takes that as nothing yet, so Ctrl-D at
the prompt did nothing. On the Unix systems the prompt now sets the
terminal as ReadPassword does and reads the line from standard input,
which reports the end of input; elsewhere, such as Windows, it still
uses ReadPassword. Ctrl-D with nothing typed gives an empty mnemonic,
refused with status 1, and the terminal settings are put back.

A test runs the tool on a pseudo-terminal (github.com/creack/pty, at a
commit past v1.1.24, whose Open on Linux can return another pty's
terminal) and types Ctrl-D at the prompt.

Model: opus-5-5
2026-10-14 21:09:14 +00:00

183 lines
4.5 KiB
Go

// Package mnemonic finds the mnemonic the keys are derived from.
package mnemonic
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"golang.org/x/term"
"sneak.berlin/go/keyfunc/internal/bip39"
)
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",
)
// The lengths a BIP-39 mnemonic may have.
const (
twelve = 12
fifteen = 15
eighteen = 18
twentyOne = 21
twentyFour = 24
)
// ErrEmpty is returned for a mnemonic with no words in it, such as the
// output of a mnemonic command that printed nothing.
var ErrEmpty = errors.New("the mnemonic is empty")
// ErrWordCount is returned for a mnemonic with a number of words that
// BIP-39 does not allow.
var ErrWordCount = errors.New("a mnemonic has 12, 15, 18, 21 or 24 words")
// ErrUnknownWord is returned for a mnemonic with a word that is not on
// the BIP-39 English word list. The error gives the word's position,
// never the word, so the mnemonic stays out of the message.
var ErrUnknownWord = errors.New("not on the BIP-39 word list")
// 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)
shell.Env = ChildEnv()
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
}
// ChildEnv is the tool's environment with the mnemonic variables taken
// out, for the programs the tool starts: the mnemonic command, ssh and
// sftp. The mnemonic, from either variable, never leaves the tool.
func ChildEnv() []string {
environ := os.Environ()
kept := make([]string, 0, len(environ))
for _, entry := range environ {
name, _, _ := strings.Cut(entry, "=")
if name == Variable || name == CommandVariable {
continue
}
kept = append(kept, entry)
}
return kept
}
// 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 := readWithoutEcho()
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading the mnemonic: %w", err)
}
return checked(typed)
}
// checked joins the words with single spaces, whatever whitespace
// separated them, since the seed is computed over the string itself,
// and refuses a mnemonic that is not a valid BIP-39 mnemonic, with an
// error that says what is wrong with it.
func checked(text string) (string, error) {
words := strings.Fields(text)
switch len(words) {
case 0:
return "", ErrEmpty
case twelve, fifteen, eighteen, twentyOne, twentyFour:
default:
return "", fmt.Errorf("%w, not %d", ErrWordCount, len(words))
}
wordList := bip39.English()
for i, word := range words {
if !slices.Contains(wordList, word) {
return "", fmt.Errorf(
"word %d of the mnemonic is %w", i+1, ErrUnknownWord,
)
}
}
joined := strings.Join(words, " ")
if !bip39.IsMnemonicValid(joined) {
return "", ErrChecksum
}
return joined, nil
}