diff --git a/pkg/cbk/cbk.go b/pkg/cbk/cbk.go new file mode 100644 index 0000000..dc07d49 --- /dev/null +++ b/pkg/cbk/cbk.go @@ -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 +} diff --git a/pkg/cbk/cbk_test.go b/pkg/cbk/cbk_test.go new file mode 100644 index 0000000..47e1d68 --- /dev/null +++ b/pkg/cbk/cbk_test.go @@ -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 +}