From 5582c02edf346cd9f83d1145ad43cbb9c53a7e6e Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 14 Oct 2026 22:05:37 +0200 Subject: [PATCH] Say why a mnemonic is refused (closes #76) 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. Judgement call: words are not lower-cased, so a capitalised word is reported as not on the word list. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 10 +++--- internal/mnemonic/mnemonic.go | 54 +++++++++++++++++++++++++++--- internal/mnemonic/mnemonic_test.go | 53 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1b741f3..da4a178 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go index 0ebdeb0..ca4418e 100644 --- a/internal/mnemonic/mnemonic.go +++ b/internal/mnemonic/mnemonic.go @@ -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 } diff --git a/internal/mnemonic/mnemonic_test.go b/internal/mnemonic/mnemonic_test.go index 97ea3cb..1a2d3be 100644 --- a/internal/mnemonic/mnemonic_test.go +++ b/internal/mnemonic/mnemonic_test.go @@ -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, "")