// 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 }