Say why a mnemonic is refused (closes #76) #86

Merged
clawbot merged 1 commits from issue-76-mnemonic-error-messages into next 2026-10-14 22:05:38 +02:00
3 changed files with 108 additions and 9 deletions
Showing only changes of commit 727fa8977b - Show all commits
+6 -4
View File
@@ -119,10 +119,12 @@ order; the first one found wins:
4. A prompt on the terminal with echo turned off.
If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
refused with a message saying so. Keys are derived from the mnemonic's words
joined by single spaces, whatever whitespace is around or between them, so one
word per line, tabs or extra spaces give the same keys.
refuses and exits with status 1. A mnemonic that is empty, does not have 12, 15,
18, 21 or 24 words, has a word that is not on the BIP-39 word list, or fails the
BIP-39 checksum is refused with a message saying which. A word that is not on
the list is named by its position, never printed. Keys are derived from the
mnemonic's words joined by single spaces, whatever whitespace is around or
between them, so one word per line, tabs or extra spaces give the same keys.
The key index, `--words`, the `--to` recipients and the file `age encrypt` or
`age decrypt` is given to read are checked before the mnemonic is looked for, so
+49 -5
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"os/exec"
"slices"
"strings"
"golang.org/x/term"
@@ -29,6 +30,28 @@ 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")
@@ -126,13 +149,34 @@ func ask() (string, error) {
// 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 does not pass the BIP-39 checksum.
func checked(words string) (string, error) {
words = strings.Join(strings.Fields(words), " ")
// 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)
if !bip39.IsMnemonicValid(words) {
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 words, nil
return joined, nil
}
+53
View File
@@ -97,6 +97,27 @@ func TestAFailingCommandSaysWhatWentWrong(t *testing.T) {
require.Contains(t, err.Error(), "nothing here")
}
func TestValidMnemonicsOf15To24WordsAreAccepted(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
// abandon repeated, then the last word that makes the checksum
// match: 15, 18, 21 and 24 words.
valid := []string{
strings.Repeat("abandon ", 14) + "address",
strings.Repeat("abandon ", 17) + "agent",
strings.Repeat("abandon ", 20) + "admit",
strings.Repeat("abandon ", 23) + "art",
}
for _, words := range valid {
t.Setenv(mnemonic.Variable, words)
got, err := mnemonic.Read(t.Context(), "")
require.NoError(t, err)
require.Equal(t, words, got)
}
}
func TestABadChecksumIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, broken())
@@ -105,6 +126,38 @@ func TestABadChecksumIsRefused(t *testing.T) {
require.ErrorIs(t, err, mnemonic.ErrChecksum)
}
func TestACommandThatPrintsNothingGivesAnEmptyMnemonic(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, "")
_, err := mnemonic.Read(t.Context(), "true")
require.ErrorIs(t, err, mnemonic.ErrEmpty)
require.NotContains(t, err.Error(), "checksum")
}
func TestAWrongWordCountIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, strings.Repeat("abandon ", 3))
_, err := mnemonic.Read(t.Context(), "")
require.ErrorIs(t, err, mnemonic.ErrWordCount)
require.ErrorContains(t, err, "not 3")
require.NotContains(t, err.Error(), "checksum")
}
func TestAWordNotOnTheListIsNamedByPositionOnly(t *testing.T) {
const typo = "abuot"
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, strings.Repeat("abandon ", 11)+typo)
_, err := mnemonic.Read(t.Context(), "")
require.ErrorIs(t, err, mnemonic.ErrUnknownWord)
require.ErrorContains(t, err, "word 12 ")
require.NotContains(t, err.Error(), typo)
require.NotContains(t, err.Error(), "checksum")
}
func TestNothingToReadAndNobodyToAsk(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "")
t.Setenv(mnemonic.Variable, "")