Implement pkg/cbk: crypto_box keypairs from BIP39 mnemonics
This commit is contained in:
+223
@@ -0,0 +1,223 @@
|
|||||||
|
// Package cbk derives NaCl crypto_box keypairs (Curve25519, XSalsa20,
|
||||||
|
// Poly1305) deterministically from BIP39 mnemonic phrases, and seals and
|
||||||
|
// opens messages with them.
|
||||||
|
//
|
||||||
|
// # Key derivation
|
||||||
|
//
|
||||||
|
// The same mnemonic and passphrase 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
|
||||||
|
// private key with the base point, via golang.org/x/crypto/nacl/box.
|
||||||
|
//
|
||||||
|
// # Sealing
|
||||||
|
//
|
||||||
|
// Seal and Open wrap box.Seal and box.Open. A sealed message is the
|
||||||
|
// 24-byte random nonce followed by the box ciphertext, so it can be stored
|
||||||
|
// or transmitted as a single opaque byte string.
|
||||||
|
package cbk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/tyler-smith/go-bip39"
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
|
||||||
|
"sneak.berlin/go/x/pkg/bip39wordlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KeySize is the length in bytes of a crypto_box public or private key.
|
||||||
|
KeySize = 32
|
||||||
|
|
||||||
|
// NonceSize is the length in bytes of the nonce that prefixes every
|
||||||
|
// sealed message.
|
||||||
|
NonceSize = 24
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
hexKeyLength = KeySize * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidKeyHex is returned by KeyFromHex for input that is not
|
||||||
|
// exactly 64 hexadecimal characters.
|
||||||
|
ErrInvalidKeyHex = errors.New("cbk: key must be 64 hexadecimal characters")
|
||||||
|
|
||||||
|
// ErrSealedTooShort is returned by Open when the input is shorter than
|
||||||
|
// the nonce that must prefix it.
|
||||||
|
ErrSealedTooShort = errors.New("cbk: sealed message is shorter than a nonce")
|
||||||
|
|
||||||
|
// ErrOpenFailed is returned by Open when authentication fails: the
|
||||||
|
// message was sealed for a different keypair, by a different sender,
|
||||||
|
// or has been modified.
|
||||||
|
ErrOpenFailed = errors.New("cbk: open failed: wrong keys or modified message")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CryptoBoxKey is a 32-byte crypto_box key, either public or private.
|
||||||
|
type CryptoBoxKey [KeySize]byte
|
||||||
|
|
||||||
|
// KeyFromHex parses a key from its 64-character hexadecimal encoding, as
|
||||||
|
// produced by Hex.
|
||||||
|
func KeyFromHex(encoded string) (CryptoBoxKey, error) {
|
||||||
|
var key CryptoBoxKey
|
||||||
|
|
||||||
|
if len(encoded) != hexKeyLength {
|
||||||
|
return key, fmt.Errorf(
|
||||||
|
"%w: got %d characters", ErrInvalidKeyHex, len(encoded),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := hex.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return key, fmt.Errorf("%w: %w", ErrInvalidKeyHex, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(key[:], decoded)
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns a copy of the key as a byte slice.
|
||||||
|
func (k CryptoBoxKey) Bytes() []byte {
|
||||||
|
return k[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex returns the key as 64 lowercase hexadecimal characters.
|
||||||
|
func (k CryptoBoxKey) Hex() string {
|
||||||
|
return hex.EncodeToString(k[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params configures New.
|
||||||
|
type Params struct {
|
||||||
|
// Mnemonic is the BIP39 mnemonic phrase. Any letter case and any
|
||||||
|
// whitespace between words is accepted; it is normalized before use.
|
||||||
|
Mnemonic string
|
||||||
|
|
||||||
|
// Passphrase is the optional BIP39 passphrase (sometimes called the
|
||||||
|
// "25th word"). It is used verbatim: it is case sensitive and is not
|
||||||
|
// trimmed. An empty passphrase is the common case.
|
||||||
|
Passphrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CryptoBoxKeypair is a crypto_box public and private key pair.
|
||||||
|
type CryptoBoxKeypair struct {
|
||||||
|
PublicKey CryptoBoxKey
|
||||||
|
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
|
||||||
|
// bip39wordlist sentinel errors.
|
||||||
|
func New(p Params) (CryptoBoxKeypair, error) {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, privateKey, err := box.GenerateKey(bytes.NewReader(privateKeyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return CryptoBoxKeypair{}, fmt.Errorf("cbk: computing public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CryptoBoxKeypair{
|
||||||
|
PublicKey: CryptoBoxKey(*publicKey),
|
||||||
|
PrivateKey: CryptoBoxKey(*privateKey),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromMnemonic derives the keypair for mnemonic with an empty passphrase.
|
||||||
|
// It is shorthand for New(Params{Mnemonic: mnemonic}).
|
||||||
|
func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) {
|
||||||
|
return New(Params{Mnemonic: mnemonic})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyBytes returns a copy of the public key as a byte slice.
|
||||||
|
func (kp CryptoBoxKeypair) PublicKeyBytes() []byte {
|
||||||
|
return kp.PublicKey.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateKeyBytes returns a copy of the private key as a byte slice.
|
||||||
|
func (kp CryptoBoxKeypair) PrivateKeyBytes() []byte {
|
||||||
|
return kp.PrivateKey.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyHex returns the public key as 64 lowercase hexadecimal
|
||||||
|
// characters.
|
||||||
|
func (kp CryptoBoxKeypair) PublicKeyHex() string {
|
||||||
|
return kp.PublicKey.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateKeyHex returns the private key as 64 lowercase hexadecimal
|
||||||
|
// characters.
|
||||||
|
func (kp CryptoBoxKeypair) PrivateKeyHex() string {
|
||||||
|
return kp.PrivateKey.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seal encrypts and authenticates message from this keypair to recipient.
|
||||||
|
// The result is a fresh random nonce followed by the ciphertext, so it is
|
||||||
|
// NonceSize + len(message) + box.Overhead bytes long. Only the holder of
|
||||||
|
// recipient's private key, knowing this keypair's public key, can Open it.
|
||||||
|
func (kp CryptoBoxKeypair) Seal(
|
||||||
|
message []byte, recipient CryptoBoxKey,
|
||||||
|
) ([]byte, error) {
|
||||||
|
var nonce [NonceSize]byte
|
||||||
|
|
||||||
|
_, err := rand.Read(nonce[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cbk: generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peersPublicKey := [KeySize]byte(recipient)
|
||||||
|
privateKey := [KeySize]byte(kp.PrivateKey)
|
||||||
|
|
||||||
|
return box.Seal(nonce[:], message, &nonce, &peersPublicKey, &privateKey), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open authenticates and decrypts a message that sender produced with Seal
|
||||||
|
// for this keypair. It returns ErrSealedTooShort if sealed cannot contain a
|
||||||
|
// nonce and ErrOpenFailed if authentication fails.
|
||||||
|
func (kp CryptoBoxKeypair) Open(
|
||||||
|
sealed []byte, sender CryptoBoxKey,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if len(sealed) < NonceSize {
|
||||||
|
return nil, ErrSealedTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonce [NonceSize]byte
|
||||||
|
|
||||||
|
copy(nonce[:], sealed[:NonceSize])
|
||||||
|
|
||||||
|
peersPublicKey := [KeySize]byte(sender)
|
||||||
|
privateKey := [KeySize]byte(kp.PrivateKey)
|
||||||
|
ciphertext := sealed[NonceSize:]
|
||||||
|
|
||||||
|
message, ok := box.Open(nil, ciphertext, &nonce, &peersPublicKey, &privateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrOpenFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package cbk_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
|
||||||
|
"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.
|
||||||
|
const (
|
||||||
|
vectorPublicKeyHex = "db9c1ec248805579d0b0fe2d5a4814f96009f66a11c91260487b79167f881b20"
|
||||||
|
|
||||||
|
vectorPrivateKeyHex = "eec3d5da4e348bd16df93b61c595bb5f75645f9aa468e72513be033a72b3d053"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vectorMnemonic returns the standard BIP39 test vector for all-zero
|
||||||
|
// 128-bit entropy.
|
||||||
|
func vectorMnemonic() string {
|
||||||
|
return strings.Repeat("abandon ", vectorRepeats) + "about"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewMatchesPinnedVector(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
keypair, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex())
|
||||||
|
assert.Equal(t, vectorPrivateKeyHex, keypair.PrivateKeyHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewNormalizesMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
canonical, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
messy, err := cbk.New(cbk.Params{
|
||||||
|
Mnemonic: " ABANDON\tAbandon " + strings.Repeat("abandon ", 9) +
|
||||||
|
"\nabout\n",
|
||||||
|
Passphrase: "",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, canonical, messy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPassphraseChangesKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
plain, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
extraWord := "TREZOR"
|
||||||
|
|
||||||
|
withExtraWord, err := cbk.New(cbk.Params{
|
||||||
|
Mnemonic: vectorMnemonic(),
|
||||||
|
Passphrase: extraWord,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEqual(t, plain.PublicKey, withExtraWord.PublicKey)
|
||||||
|
assert.NotEqual(t, plain.PrivateKey, withExtraWord.PrivateKey)
|
||||||
|
|
||||||
|
again, err := cbk.New(cbk.Params{
|
||||||
|
Mnemonic: vectorMnemonic(),
|
||||||
|
Passphrase: extraWord,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, withExtraWord, again, "derivation is deterministic")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsInvalidMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := cbk.FromMnemonic("abandon bogus about")
|
||||||
|
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
|
||||||
|
|
||||||
|
_, err = cbk.FromMnemonic(strings.Repeat("abandon ", vectorRepeats) + "bogus")
|
||||||
|
require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyAccessors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
keypair, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes())
|
||||||
|
assert.Equal(t, keypair.PrivateKey[:], keypair.PrivateKeyBytes())
|
||||||
|
assert.Len(t, keypair.PublicKeyBytes(), cbk.KeySize)
|
||||||
|
assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize)
|
||||||
|
assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex())
|
||||||
|
assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFromHexRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
keypair, err := cbk.FromMnemonic(vectorMnemonic())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
parsed, err := cbk.KeyFromHex(keypair.PublicKeyHex())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, keypair.PublicKey, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFromHexRejectsBadInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := cbk.KeyFromHex("abcd")
|
||||||
|
require.ErrorIs(t, err, cbk.ErrInvalidKeyHex)
|
||||||
|
|
||||||
|
notHex := "zz" + keypairForTest(t).PublicKeyHex()[2:]
|
||||||
|
|
||||||
|
_, err = cbk.KeyFromHex(notHex)
|
||||||
|
require.ErrorIs(t, err, cbk.ErrInvalidKeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealOpenRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
alice := keypairForTest(t)
|
||||||
|
bob := keypairForTest(t)
|
||||||
|
message := []byte("the quick brown fox jumps over the lazy dog")
|
||||||
|
|
||||||
|
sealed, err := alice.Seal(message, bob.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, sealed, cbk.NonceSize+len(message)+box.Overhead)
|
||||||
|
|
||||||
|
opened, err := bob.Open(sealed, alice.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, message, opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSealUsesFreshNonces(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
alice := keypairForTest(t)
|
||||||
|
bob := keypairForTest(t)
|
||||||
|
message := []byte("same message twice")
|
||||||
|
|
||||||
|
first, err := alice.Seal(message, bob.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
second, err := alice.Seal(message, bob.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEqual(t, first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenRejectsTamperingAndWrongKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
alice := keypairForTest(t)
|
||||||
|
bob := keypairForTest(t)
|
||||||
|
eve := keypairForTest(t)
|
||||||
|
message := []byte("for bob only")
|
||||||
|
|
||||||
|
sealed, err := alice.Seal(message, bob.PublicKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = eve.Open(sealed, alice.PublicKey)
|
||||||
|
require.ErrorIs(t, err, cbk.ErrOpenFailed, "wrong recipient")
|
||||||
|
|
||||||
|
_, err = bob.Open(sealed, eve.PublicKey)
|
||||||
|
require.ErrorIs(t, err, cbk.ErrOpenFailed, "wrong sender")
|
||||||
|
|
||||||
|
tampered := append([]byte(nil), sealed...)
|
||||||
|
tampered[len(tampered)-1] ^= 0x01
|
||||||
|
|
||||||
|
_, err = bob.Open(tampered, alice.PublicKey)
|
||||||
|
require.ErrorIs(t, err, cbk.ErrOpenFailed, "modified ciphertext")
|
||||||
|
|
||||||
|
_, err = bob.Open(sealed[:cbk.NonceSize-1], alice.PublicKey)
|
||||||
|
require.ErrorIs(t, err, cbk.ErrSealedTooShort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keypairForTest derives a keypair from a freshly generated mnemonic.
|
||||||
|
func keypairForTest(t *testing.T) cbk.CryptoBoxKeypair {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mnemonic, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
keypair, err := cbk.FromMnemonic(mnemonic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return keypair
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user