mirror of
https://github.com/muun/recovery.git
synced 2025-11-12 14:51:37 -05:00
Update project structure and build process
This commit is contained in:
168
libwallet/recoverycode/recoverycode.go
Normal file
168
libwallet/recoverycode/recoverycode.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package recoverycode
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec"
|
||||
)
|
||||
|
||||
const (
|
||||
kdfKey = "muun:rc"
|
||||
kdfIterations = 512
|
||||
kdfBlockSize = 8
|
||||
kdfParallelizationFactor = 1
|
||||
kdfOutputLength = 32
|
||||
)
|
||||
|
||||
// CurrentVersion defines the current version number for the recovery codes.
|
||||
const CurrentVersion = 2
|
||||
|
||||
// Alphabet contains all upper-case characters except for numbers/letters that
|
||||
// look alike.
|
||||
const Alphabet = "ABCDEFHJKLMNPQRSTUVWXYZ2345789"
|
||||
|
||||
// AlphabetLegacy constains the letters that pre version 2 recovery codes can
|
||||
// contain.
|
||||
const AlphabetLegacy = "ABCDEFHJKMNPQRSTUVWXYZ2345789"
|
||||
|
||||
// Generate creates a new random recovery code using a cryptographically
|
||||
// secure random number generator.
|
||||
func Generate() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('L')
|
||||
// we subtract 2 from the version number so that the first character of the
|
||||
// alphabet correspods to version 2
|
||||
sb.WriteByte(Alphabet[CurrentVersion-2])
|
||||
|
||||
codeLen := 30
|
||||
for i := 0; i < codeLen; i++ {
|
||||
sb.WriteByte(randChar(Alphabet))
|
||||
j := i + 3 // we count the two bytes we wrote before the loop
|
||||
if j != 0 && i != codeLen-1 && j%4 == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// randChar returns a random character from the given string
|
||||
//
|
||||
// The algorithm was inspired by BSD arc4random_uniform function to avoid
|
||||
// modulo bias and ensure a uniform distribution
|
||||
// http://cvsweb.openbsd.org/cgi-bin/cvsweb/~checkout~/src/lib/libc/crypt/arc4random_uniform.c
|
||||
func randChar(chars string) byte {
|
||||
clen := len(chars)
|
||||
min := -clen % clen
|
||||
for {
|
||||
var b [1]byte
|
||||
_, err := rand.Read(b[:])
|
||||
if err != nil {
|
||||
panic("could not read enough random bytes for recovery code")
|
||||
}
|
||||
r := int(b[0])
|
||||
if r < min {
|
||||
continue
|
||||
}
|
||||
return chars[r%clen]
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertToKey generates a private key using the recovery code as a seed.
|
||||
//
|
||||
// The salt parameter is only used for version 1 codes. It will be ignored
|
||||
// for version 2+ codes.
|
||||
func ConvertToKey(code, salt string) (*btcec.PrivateKey, error) {
|
||||
version, err := Version(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input []byte
|
||||
|
||||
switch version {
|
||||
case 1:
|
||||
saltBytes, err := hex.DecodeString(salt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode salt: %w", err)
|
||||
}
|
||||
|
||||
input, err = scrypt.Key(
|
||||
[]byte(code),
|
||||
saltBytes,
|
||||
kdfIterations,
|
||||
kdfBlockSize,
|
||||
kdfParallelizationFactor,
|
||||
kdfOutputLength,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case 2:
|
||||
mac := hmac.New(sha256.New, []byte(kdfKey))
|
||||
mac.Write([]byte(code))
|
||||
input = mac.Sum(nil)
|
||||
}
|
||||
|
||||
// 2nd return value is the pub key which we don't need right now
|
||||
priv, _ := btcec.PrivKeyFromBytes(btcec.S256(), input)
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// Validate returns an error if the recovery code is not valid or nil otherwise.
|
||||
func Validate(code string) error {
|
||||
_, err := Version(code)
|
||||
return err
|
||||
}
|
||||
|
||||
// Version returns the version that this recovery code corresponds to.
|
||||
func Version(code string) (int, error) {
|
||||
if len(code) != 39 { // code contains 32 RC chars + 7 separator chars
|
||||
return 0, fmt.Errorf("invalid recovery code length %v", len(code))
|
||||
}
|
||||
if code[0] == 'L' { // version 2+ codes always start with L
|
||||
idx := strings.IndexByte(Alphabet, code[1])
|
||||
if idx == -1 {
|
||||
return 0, errors.New("invalid recovery code version")
|
||||
}
|
||||
if !validateAlphabet(code, Alphabet) {
|
||||
return 0, fmt.Errorf("invalid recovery code characters")
|
||||
}
|
||||
// we add 2 to the idx because the first letter corresponds to code version 2
|
||||
version := idx + 2
|
||||
if version > CurrentVersion {
|
||||
return 0, fmt.Errorf("unrecognized recovery code version: %d", version)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
if !validateAlphabet(code, AlphabetLegacy) {
|
||||
return 0, fmt.Errorf("invalid recovery code characters")
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func validateAlphabet(s, alphabet string) bool {
|
||||
var charsInBlock int
|
||||
for _, c := range s {
|
||||
if charsInBlock == 4 {
|
||||
if c == '-' {
|
||||
charsInBlock = 0
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(alphabet, string(c)) {
|
||||
return false
|
||||
}
|
||||
charsInBlock++
|
||||
}
|
||||
return true
|
||||
}
|
||||
86
libwallet/recoverycode/recoverycode_test.go
Normal file
86
libwallet/recoverycode/recoverycode_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package recoverycode
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
code := Generate()
|
||||
if err := Validate(code); err != nil {
|
||||
t.Fatalf("expected generated recovery code to be valid, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
desc: "empty string",
|
||||
input: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid version",
|
||||
input: "LB2Q-48Z3-25JR-S5JB-5SUS-HXHJ-RCMM-8YUA",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid characters",
|
||||
input: "LA2Q-48Z3-25JR-S51B-5SUS-HXHJ-RCMM-8YUA",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
desc: "invalid length",
|
||||
input: "LA2Q-48Z3-25JR-SB5S-USHX-HJRC-MM8YUA",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
desc: "valid",
|
||||
input: "LA2Q-48Z3-25JR-S5JB-5SUS-HXHJ-RCMM-8YUA",
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
err := Validate(tt.input)
|
||||
if (err != nil) != tt.expectErr {
|
||||
t.Errorf("unexpected result: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToKey(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
desc: "legacy recovery code",
|
||||
input: "R52Q-48Z3-25JR-S5JB-5SUS-HXHJ-RCMM-8YUA",
|
||||
expected: "ade3fe99c608fd04484bce1ccf2889a5096f68f4b6b459e7f9ee9f0ada0a2782",
|
||||
},
|
||||
{
|
||||
desc: "version 2 recovery code",
|
||||
input: "LA2Q-48Z3-25JR-S5JB-5SUS-HXHJ-RCMM-8YUA",
|
||||
expected: "0e1446153d4cafb073110739608fdd76b8712221476ec198cf35e1d74d274e83",
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
key, err := ConvertToKey(tt.input, "FFFFFFFF")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
got := hex.EncodeToString(key.Serialize())
|
||||
if got != tt.expected {
|
||||
t.Errorf("expected %v but got %v", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user