check / check (push) Waiting to run
Every way a mnemonic could be invalid was reported as a checksum failure, including an empty one. It is now refused as empty, as having a word count other than 12, 15, 18, 21 or 24, as having a word that is not on the BIP-39 word list, or as failing the checksum. A word not on the list is named by its position, never by the word itself. Model: opus-5-5
183 lines
4.5 KiB
Go
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 := term.ReadPassword(fd)
|
|
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading the mnemonic: %w", err)
|
|
}
|
|
|
|
return checked(string(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
|
|
}
|