feat(wallet): mnemonic seed encode/decode with Electrum wordlist
24-word Electrum encoding (4 bytes → 3 words × 8 groups) plus CRC32 checksum word. 1626-word dictionary extracted from C++ source. Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
b506c9c0a1
commit
5b677d1f36
3 changed files with 1841 additions and 0 deletions
85
wallet/mnemonic.go
Normal file
85
wallet/mnemonic.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package wallet
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const numWords = 1626
|
||||
|
||||
// MnemonicEncode converts a 32-byte secret key to a 25-word mnemonic phrase.
|
||||
func MnemonicEncode(key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("wallet: mnemonic encode requires 32 bytes, got %d", len(key))
|
||||
}
|
||||
|
||||
words := make([]string, 0, 25)
|
||||
n := uint32(numWords)
|
||||
|
||||
for i := 0; i < 32; i += 4 {
|
||||
val := binary.LittleEndian.Uint32(key[i : i+4])
|
||||
w1 := val % n
|
||||
w2 := ((val / n) + w1) % n
|
||||
w3 := (((val / n) / n) + w2) % n
|
||||
words = append(words, wordlist[w1], wordlist[w2], wordlist[w3])
|
||||
}
|
||||
|
||||
checkIdx := checksumIndex(words)
|
||||
words = append(words, words[checkIdx])
|
||||
|
||||
return strings.Join(words, " "), nil
|
||||
}
|
||||
|
||||
// MnemonicDecode converts a 25-word mnemonic phrase to a 32-byte secret key.
|
||||
func MnemonicDecode(phrase string) ([32]byte, error) {
|
||||
var key [32]byte
|
||||
|
||||
words := strings.Fields(phrase)
|
||||
if len(words) != 25 {
|
||||
return key, fmt.Errorf("wallet: mnemonic requires 25 words, got %d", len(words))
|
||||
}
|
||||
|
||||
expected := checksumIndex(words[:24])
|
||||
if words[24] != words[expected] {
|
||||
return key, fmt.Errorf("wallet: mnemonic checksum failed")
|
||||
}
|
||||
|
||||
n := uint32(numWords)
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
w1, ok1 := wordIndex[words[i*3]]
|
||||
w2, ok2 := wordIndex[words[i*3+1]]
|
||||
w3, ok3 := wordIndex[words[i*3+2]]
|
||||
if !ok1 || !ok2 || !ok3 {
|
||||
word := words[i*3]
|
||||
if !ok2 {
|
||||
word = words[i*3+1]
|
||||
}
|
||||
if !ok3 {
|
||||
word = words[i*3+2]
|
||||
}
|
||||
return key, fmt.Errorf("wallet: unknown mnemonic word %q", word)
|
||||
}
|
||||
|
||||
val := uint32(w1) +
|
||||
n*(((n-uint32(w1))+uint32(w2))%n) +
|
||||
n*n*(((n-uint32(w2))+uint32(w3))%n)
|
||||
binary.LittleEndian.PutUint32(key[i*4:i*4+4], val)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func checksumIndex(words []string) int {
|
||||
var prefixes string
|
||||
for _, w := range words {
|
||||
if len(w) >= 3 {
|
||||
prefixes += w[:3]
|
||||
} else {
|
||||
prefixes += w
|
||||
}
|
||||
}
|
||||
return int(crc32.ChecksumIEEE([]byte(prefixes))) % len(words)
|
||||
}
|
||||
113
wallet/mnemonic_test.go
Normal file
113
wallet/mnemonic_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package wallet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWordlistLength(t *testing.T) {
|
||||
if len(wordlist) != 1626 {
|
||||
t.Fatalf("wordlist length = %d, want 1626", len(wordlist))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordlistFirstLast(t *testing.T) {
|
||||
if wordlist[0] != "like" {
|
||||
t.Errorf("wordlist[0] = %q, want %q", wordlist[0], "like")
|
||||
}
|
||||
if wordlist[1625] != "weary" {
|
||||
t.Errorf("wordlist[1625] = %q, want %q", wordlist[1625], "weary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordIndexConsistency(t *testing.T) {
|
||||
for i, w := range wordlist {
|
||||
idx, ok := wordIndex[w]
|
||||
if !ok {
|
||||
t.Fatalf("word %q not in index", w)
|
||||
}
|
||||
if idx != i {
|
||||
t.Fatalf("wordIndex[%q] = %d, want %d", w, idx, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicRoundTrip(t *testing.T) {
|
||||
var key [32]byte
|
||||
|
||||
phrase, err := MnemonicEncode(key[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
words := strings.Fields(phrase)
|
||||
if len(words) != 25 {
|
||||
t.Fatalf("got %d words, want 25", len(words))
|
||||
}
|
||||
|
||||
decoded, err := MnemonicDecode(phrase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded != key {
|
||||
t.Fatalf("round-trip failed: got %x, want %x", decoded, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicRoundTripNonZero(t *testing.T) {
|
||||
var key [32]byte
|
||||
for i := range key {
|
||||
key[i] = byte(i * 7)
|
||||
}
|
||||
|
||||
phrase, err := MnemonicEncode(key[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
decoded, err := MnemonicDecode(phrase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded != key {
|
||||
t.Fatalf("round-trip failed: got %x, want %x", decoded, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicInvalidWordCount(t *testing.T) {
|
||||
_, err := MnemonicDecode("like just love")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 3 words")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicInvalidWord(t *testing.T) {
|
||||
phrase := "like just love know never want time out there make look eye down only think call hand high keep last long make new zzzznotaword"
|
||||
_, err := MnemonicDecode(phrase)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid word")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicBadChecksum(t *testing.T) {
|
||||
var key [32]byte
|
||||
|
||||
phrase, _ := MnemonicEncode(key[:])
|
||||
words := strings.Fields(phrase)
|
||||
words[24] = "never"
|
||||
if words[24] == words[0] {
|
||||
words[24] = "want"
|
||||
}
|
||||
|
||||
_, err := MnemonicDecode(strings.Join(words, " "))
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMnemonicInvalidLength(t *testing.T) {
|
||||
_, err := MnemonicEncode([]byte{1, 2, 3})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-32-byte input")
|
||||
}
|
||||
}
|
||||
1643
wallet/wordlist.go
Normal file
1643
wallet/wordlist.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue