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 <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #86.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user