Derive cbk keys with BIP85 instead of an ad hoc HKDF label
The private key is now the BIP85 HEX application output for 32 bytes at
m/83696968'/128169'/32'/{index}' from the BIP32 master key of the BIP39
seed, via sneak.berlin/go/secret/pkg/bip85, so any BIP85 tool reproduces
it and one mnemonic backs many keypairs through Params.Index. Tests
check the mnemonic-to-master step and the HEX application against the
BIP85 specification vectors and pin the resulting keypair.
This commit is contained in:
+56
-22
@@ -1,43 +1,56 @@
|
||||
// Package cbk derives NaCl crypto_box keypairs (Curve25519) deterministically
|
||||
// from BIP39 mnemonic phrases. That is all it does; the resulting keys are
|
||||
// plain 32-byte crypto_box keys for use with golang.org/x/crypto/nacl/box.
|
||||
// from BIP39 mnemonic phrases using BIP85. That is all it does; the
|
||||
// resulting keys are plain 32-byte crypto_box keys for use with
|
||||
// golang.org/x/crypto/nacl/box.
|
||||
//
|
||||
// The same mnemonic and passphrase always produce the same keypair:
|
||||
// The same mnemonic, passphrase, and index always produce the same keypair:
|
||||
//
|
||||
// 1. The mnemonic is validated and canonicalized with
|
||||
// bip39wordlist.NormalizeWordlist (lowercase, single spaces).
|
||||
// 2. The standard 64-byte BIP39 seed is computed from the canonical
|
||||
// mnemonic and the optional passphrase with PBKDF2-HMAC-SHA512 (2048
|
||||
// rounds, salt "mnemonic" + passphrase), exactly as a BIP39 wallet
|
||||
// would.
|
||||
// 3. The 32-byte crypto_box private key is derived from that seed with
|
||||
// HKDF-SHA256, using no salt and DerivationInfo as the info string.
|
||||
// 4. The public key is the Curve25519 scalar multiplication of the
|
||||
// rounds, salt "mnemonic" + passphrase), exactly as a wallet would.
|
||||
// 3. The seed becomes a BIP32 master key.
|
||||
// 4. BIP85 (sneak.berlin/go/secret/pkg/bip85) derives 32 bytes of
|
||||
// application entropy from the master key with the HEX application,
|
||||
// at path m/83696968'/128169'/32'/{index}'. Those bytes are the
|
||||
// crypto_box private key.
|
||||
// 5. The public key is the Curve25519 scalar multiplication of the
|
||||
// private key with the base point, via golang.org/x/crypto/nacl/box.
|
||||
//
|
||||
// Because the entropy comes from BIP85, any BIP85-capable tool given the
|
||||
// same mnemonic reproduces the private key, and the index lets one
|
||||
// mnemonic back any number of independent keypairs.
|
||||
package cbk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"sneak.berlin/go/secret/pkg/bip85"
|
||||
"sneak.berlin/go/x/pkg/bip39wordlist"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeySize is the length in bytes of a crypto_box public or private key.
|
||||
// It is also the byte count requested from the BIP85 HEX application.
|
||||
KeySize = 32
|
||||
|
||||
// DerivationInfo is the HKDF info string that binds derived keys to
|
||||
// this package. Changing it changes every derived key.
|
||||
DerivationInfo = "sneak.berlin/go/x/pkg/cbk crypto_box v1"
|
||||
// MaxIndex is the largest valid Params.Index. BIP85 path components
|
||||
// are hardened BIP32 child indexes, which leaves 31 bits.
|
||||
MaxIndex = hdkeychain.HardenedKeyStart - 1
|
||||
)
|
||||
|
||||
// ErrIndexTooLarge is returned by New when Params.Index exceeds MaxIndex.
|
||||
var ErrIndexTooLarge = errors.New("cbk: index exceeds the BIP32 hardened range")
|
||||
|
||||
// CryptoBoxKey is a 32-byte crypto_box key, either public or private.
|
||||
type CryptoBoxKey [KeySize]byte
|
||||
|
||||
@@ -61,6 +74,11 @@ type Params struct {
|
||||
// "25th word"). It is used verbatim: it is case sensitive and is not
|
||||
// trimmed. An empty passphrase is the common case.
|
||||
Passphrase string
|
||||
|
||||
// Index selects which of the keypairs derivable from the mnemonic to
|
||||
// return. It is the final component of the BIP85 derivation path, so
|
||||
// each index yields an independent keypair. Zero is the first.
|
||||
Index uint32
|
||||
}
|
||||
|
||||
// CryptoBoxKeypair is a crypto_box public and private key pair.
|
||||
@@ -69,20 +87,36 @@ type CryptoBoxKeypair struct {
|
||||
PrivateKey CryptoBoxKey
|
||||
}
|
||||
|
||||
// New derives the keypair for the mnemonic and passphrase in p. If the
|
||||
// mnemonic is not valid it returns an error wrapping one of the
|
||||
// New derives the keypair for the mnemonic, passphrase, and index in p. If
|
||||
// the mnemonic is not valid it returns an error wrapping one of the
|
||||
// bip39wordlist sentinel errors.
|
||||
func New(p Params) (CryptoBoxKeypair, error) {
|
||||
if p.Index > MaxIndex {
|
||||
return CryptoBoxKeypair{}, fmt.Errorf("%w: %d", ErrIndexTooLarge, p.Index)
|
||||
}
|
||||
|
||||
mnemonic, err := bip39wordlist.NormalizeWordlist(p.Mnemonic)
|
||||
if err != nil {
|
||||
return CryptoBoxKeypair{}, err
|
||||
}
|
||||
|
||||
seed := bip39.NewSeed(mnemonic, p.Passphrase)
|
||||
|
||||
privateKeyBytes, err := hkdf.Key(sha256.New, seed, nil, DerivationInfo, KeySize)
|
||||
// The network parameters only choose the version bytes used when a
|
||||
// key is serialized; they do not affect derivation.
|
||||
masterKey, err := hdkeychain.NewMaster(
|
||||
bip39.NewSeed(mnemonic, p.Passphrase), &chaincfg.MainNetParams,
|
||||
)
|
||||
if err != nil {
|
||||
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving private key: %w", err)
|
||||
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP32 master key: %w", err)
|
||||
}
|
||||
|
||||
privateKeyHex, err := bip85.DeriveHex(masterKey, KeySize, p.Index)
|
||||
if err != nil {
|
||||
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP85 entropy: %w", err)
|
||||
}
|
||||
|
||||
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
|
||||
if err != nil {
|
||||
return CryptoBoxKeypair{}, fmt.Errorf("cbk: decoding BIP85 entropy: %w", err)
|
||||
}
|
||||
|
||||
publicKey, privateKey, err := box.GenerateKey(bytes.NewReader(privateKeyBytes))
|
||||
@@ -96,10 +130,10 @@ func New(p Params) (CryptoBoxKeypair, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FromMnemonic derives the keypair for mnemonic with an empty passphrase.
|
||||
// It is shorthand for New(Params{Mnemonic: mnemonic}).
|
||||
// FromMnemonic derives the keypair for mnemonic with an empty passphrase
|
||||
// at index zero. It is shorthand for New(Params{Mnemonic: mnemonic}).
|
||||
func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) {
|
||||
return New(Params{Mnemonic: mnemonic, Passphrase: ""})
|
||||
return New(Params{Mnemonic: mnemonic, Passphrase: "", Index: 0})
|
||||
}
|
||||
|
||||
// PublicKeyBytes returns a copy of the public key as a byte slice.
|
||||
|
||||
+129
-32
@@ -4,69 +4,152 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
|
||||
"sneak.berlin/go/secret/pkg/bip85"
|
||||
"sneak.berlin/go/x/pkg/bip39wordlist"
|
||||
"sneak.berlin/go/x/pkg/cbk"
|
||||
)
|
||||
|
||||
// vectorRepeats is how many times "abandon" appears in the standard BIP39
|
||||
// test vector for all-zero 128-bit entropy, which ends with "about".
|
||||
const vectorRepeats = bip39wordlist.MinWordCount - 1
|
||||
|
||||
// The keys below were pinned when the derivation was first implemented.
|
||||
// If this test starts failing, the derivation has changed and every key
|
||||
// anyone has derived with this package is no longer reproducible.
|
||||
// The BIP85 specification publishes its test vectors against one master
|
||||
// key. specMnemonic is the BIP39 phrase that produces that master key with
|
||||
// an empty passphrase, so the whole mnemonic-to-entropy chain this package
|
||||
// relies on can be checked against the specification.
|
||||
const (
|
||||
vectorPublicKeyHex = "db9c1ec248805579d0b0fe2d5a4814f96009f66a11c91260487b79167f881b20"
|
||||
specMnemonic = "install scatter logic circle pencil average fall shoe " +
|
||||
"quantum disease suspect usage"
|
||||
|
||||
vectorPrivateKeyHex = "eec3d5da4e348bd16df93b61c595bb5f75645f9aa468e72513be033a72b3d053"
|
||||
specMasterKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8" +
|
||||
"kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
|
||||
|
||||
// specHex64 is the specification's HEX application vector for
|
||||
// m/83696968'/128169'/64'/0'.
|
||||
specHex64 = "492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e7" +
|
||||
"1aa2d645442f878555d078fd1f1f67e368976f04137b1f7a" +
|
||||
"0d19232136ca50c44614af72b5582a5c"
|
||||
)
|
||||
|
||||
// vectorMnemonic returns the standard BIP39 test vector for all-zero
|
||||
// 128-bit entropy.
|
||||
func vectorMnemonic() string {
|
||||
return strings.Repeat("abandon ", vectorRepeats) + "about"
|
||||
// The keys below are the keypair for specMnemonic at index 0, pinned when
|
||||
// the BIP85 derivation was implemented. If this test starts failing, the
|
||||
// derivation has changed and existing keys are no longer reproducible.
|
||||
const (
|
||||
vectorPublicKeyHex = "dbef711b3f340d336eb99cd8d0dbd491e1edce8eaad71cd66be228032a9a743e"
|
||||
|
||||
vectorPrivateKeyHex = "ea3ceb0b02ee8e587779c63f4b7b3a21e950a213f1ec53cab608d13e8796e6dc"
|
||||
)
|
||||
|
||||
func TestSpecMnemonicProducesSpecMasterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
masterKey := specMasterKeyFromMnemonic(t)
|
||||
assert.Equal(t, specMasterKey, masterKey.String())
|
||||
|
||||
hex64, err := bip85.DeriveHex(masterKey, 64, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, specHex64, hex64)
|
||||
}
|
||||
|
||||
func TestNewMatchesPinnedVector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keypair, err := cbk.FromMnemonic(vectorMnemonic())
|
||||
keypair, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex())
|
||||
assert.Equal(t, vectorPrivateKeyHex, keypair.PrivateKeyHex())
|
||||
}
|
||||
|
||||
func TestPrivateKeyIsBIP85HexApplicationOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
masterKey := specMasterKeyFromMnemonic(t)
|
||||
|
||||
for _, index := range []uint32{0, 1, 7} {
|
||||
expected, err := bip85.DeriveHex(masterKey, cbk.KeySize, index)
|
||||
require.NoError(t, err)
|
||||
|
||||
keypair, err := cbk.New(cbk.Params{
|
||||
Mnemonic: specMnemonic,
|
||||
Passphrase: "",
|
||||
Index: index,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expected, keypair.PrivateKeyHex(), "index %d", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIndexSelectsIndependentKeypairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Passphrase: "", Index: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Passphrase: "", Index: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.PublicKey, second.PublicKey)
|
||||
assert.NotEqual(t, first.PrivateKey, second.PrivateKey)
|
||||
|
||||
fromMnemonic, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first, fromMnemonic, "FromMnemonic uses index 0")
|
||||
}
|
||||
|
||||
func TestNewRejectsIndexBeyondHardenedRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cbk.New(cbk.Params{
|
||||
Mnemonic: specMnemonic,
|
||||
Passphrase: "",
|
||||
Index: cbk.MaxIndex + 1,
|
||||
})
|
||||
require.ErrorIs(t, err, cbk.ErrIndexTooLarge)
|
||||
|
||||
_, err = cbk.New(cbk.Params{
|
||||
Mnemonic: specMnemonic,
|
||||
Passphrase: "",
|
||||
Index: cbk.MaxIndex,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNewNormalizesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical, err := cbk.FromMnemonic(vectorMnemonic())
|
||||
canonical, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
messy, err := cbk.New(cbk.Params{
|
||||
Mnemonic: " ABANDON\tAbandon " + strings.Repeat("abandon ", 9) +
|
||||
"\nabout\n",
|
||||
words := strings.Fields(specMnemonic)
|
||||
messy := " " + strings.ToUpper(words[0]) + "\t" +
|
||||
strings.Join(words[1:], " ") + "\n"
|
||||
|
||||
normalized, err := cbk.New(cbk.Params{
|
||||
Mnemonic: messy,
|
||||
Passphrase: "",
|
||||
Index: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, canonical, messy)
|
||||
assert.Equal(t, canonical, normalized)
|
||||
}
|
||||
|
||||
func TestNewPassphraseChangesKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
plain, err := cbk.FromMnemonic(vectorMnemonic())
|
||||
plain, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
extraWord := "TREZOR"
|
||||
|
||||
withExtraWord, err := cbk.New(cbk.Params{
|
||||
Mnemonic: vectorMnemonic(),
|
||||
Mnemonic: specMnemonic,
|
||||
Passphrase: extraWord,
|
||||
Index: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -74,8 +157,9 @@ func TestNewPassphraseChangesKeys(t *testing.T) {
|
||||
assert.NotEqual(t, plain.PrivateKey, withExtraWord.PrivateKey)
|
||||
|
||||
again, err := cbk.New(cbk.Params{
|
||||
Mnemonic: vectorMnemonic(),
|
||||
Mnemonic: specMnemonic,
|
||||
Passphrase: extraWord,
|
||||
Index: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -85,20 +169,17 @@ func TestNewPassphraseChangesKeys(t *testing.T) {
|
||||
func TestNewDifferentMnemonicsGiveDifferentKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
|
||||
other, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
|
||||
specKeypair, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
firstKeypair, err := cbk.FromMnemonic(first)
|
||||
otherKeypair, err := cbk.FromMnemonic(other)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondKeypair, err := cbk.FromMnemonic(second)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, firstKeypair.PublicKey, secondKeypair.PublicKey)
|
||||
assert.NotEqual(t, firstKeypair.PrivateKey, secondKeypair.PrivateKey)
|
||||
assert.NotEqual(t, specKeypair.PublicKey, otherKeypair.PublicKey)
|
||||
assert.NotEqual(t, specKeypair.PrivateKey, otherKeypair.PrivateKey)
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidMnemonic(t *testing.T) {
|
||||
@@ -107,14 +188,17 @@ func TestNewRejectsInvalidMnemonic(t *testing.T) {
|
||||
_, err := cbk.FromMnemonic("abandon bogus about")
|
||||
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
|
||||
|
||||
_, err = cbk.FromMnemonic(strings.Repeat("abandon ", vectorRepeats) + "bogus")
|
||||
words := strings.Fields(specMnemonic)
|
||||
words[len(words)-1] = "bogus"
|
||||
|
||||
_, err = cbk.FromMnemonic(strings.Join(words, " "))
|
||||
require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord)
|
||||
}
|
||||
|
||||
func TestKeyAccessors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keypair, err := cbk.FromMnemonic(vectorMnemonic())
|
||||
keypair, err := cbk.FromMnemonic(specMnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes())
|
||||
@@ -125,3 +209,16 @@ func TestKeyAccessors(t *testing.T) {
|
||||
assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex())
|
||||
assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize)
|
||||
}
|
||||
|
||||
// specMasterKeyFromMnemonic builds the BIP32 master key from specMnemonic
|
||||
// the same way the package does, independently of it.
|
||||
func specMasterKeyFromMnemonic(t *testing.T) *hdkeychain.ExtendedKey {
|
||||
t.Helper()
|
||||
|
||||
seed := bip39.NewSeed(specMnemonic, "")
|
||||
|
||||
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
require.NoError(t, err)
|
||||
|
||||
return masterKey
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user