diff --git a/README.md b/README.md index c4a0899..0cbb964 100644 --- a/README.md +++ b/README.md @@ -67,29 +67,14 @@ func main() { } // The same mnemonic (and optional passphrase) always yields the same - // keypair, on any machine. - alice, err := cbk.New(cbk.Params{Mnemonic: mnemonic}) + // keypair, on any machine. The keys are plain crypto_box keys for use + // with golang.org/x/crypto/nacl/box. + keypair, err := cbk.New(cbk.Params{Mnemonic: mnemonic}) if err != nil { panic(err) } - bob, err := cbk.FromMnemonic("abandon abandon abandon abandon abandon " + - "abandon abandon abandon abandon abandon abandon about") - if err != nil { - panic(err) - } - - sealed, err := alice.Seal([]byte("hello bob"), bob.PublicKey) - if err != nil { - panic(err) - } - - plain, err := bob.Open(sealed, alice.PublicKey) - if err != nil { - panic(err) - } - - fmt.Println(string(plain), alice.PublicKeyHex()) + fmt.Println(keypair.PublicKeyHex()) } ``` @@ -143,9 +128,9 @@ directory is never read. ### `pkg/cbk` -Derives NaCl crypto_box keypairs (Curve25519, XSalsa20, Poly1305) -deterministically from BIP39 mnemonics, and seals and opens messages with them. -The derivation is fixed and documented so keys are reproducible: +Derives NaCl crypto_box keypairs (Curve25519) deterministically from BIP39 +mnemonics, and nothing else. The derivation is fixed and documented so keys are +reproducible: 1. The mnemonic is validated and canonicalized with `bip39wordlist.NormalizeWordlist`. @@ -156,16 +141,13 @@ The derivation is fixed and documented so keys are reproducible: `HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`. 4. The public key is the Curve25519 base-point multiple of the private key. -A sealed message is a 24-byte random nonce followed by the box ciphertext, so it -can be handled as a single opaque byte string. The API: +The resulting keys are plain 32-byte crypto_box keys for use with +`golang.org/x/crypto/nacl/box`. The API: - `cbk.New(cbk.Params{Mnemonic, Passphrase})` and `cbk.FromMnemonic(m)` return a `CryptoBoxKeypair`. -- `CryptoBoxKeypair.Seal(message, recipientPublicKey)` and - `CryptoBoxKeypair.Open(sealed, senderPublicKey)`. -- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, `cbk.KeyFromHex(s)`, and the - `PublicKeyHex`, `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors - on the keypair. +- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, and the `PublicKeyHex`, + `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors on the keypair. The test suite pins the keypair derived from the standard all-zero-entropy BIP39 test vector. If that test ever fails, the derivation has changed and existing diff --git a/pkg/cbk/cbk.go b/pkg/cbk/cbk.go index dc07d49..016a2ee 100644 --- a/pkg/cbk/cbk.go +++ b/pkg/cbk/cbk.go @@ -1,8 +1,6 @@ -// Package cbk derives NaCl crypto_box keypairs (Curve25519, XSalsa20, -// Poly1305) deterministically from BIP39 mnemonic phrases, and seals and -// opens messages with them. -// -// # Key derivation +// 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. // // The same mnemonic and passphrase always produce the same keypair: // @@ -16,21 +14,13 @@ // 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" @@ -43,56 +33,14 @@ 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[:] @@ -151,7 +99,7 @@ func New(p Params) (CryptoBoxKeypair, error) { // 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}) + return New(Params{Mnemonic: mnemonic, Passphrase: ""}) } // PublicKeyBytes returns a copy of the public key as a byte slice. @@ -175,49 +123,3 @@ func (kp CryptoBoxKeypair) PublicKeyHex() string { 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 index 47e1d68..c109616 100644 --- a/pkg/cbk/cbk_test.go +++ b/pkg/cbk/cbk_test.go @@ -6,7 +6,6 @@ import ( "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" @@ -83,6 +82,25 @@ func TestNewPassphraseChangesKeys(t *testing.T) { assert.Equal(t, withExtraWord, again, "derivation is deterministic") } +func TestNewDifferentMnemonicsGiveDifferentKeys(t *testing.T) { + t.Parallel() + + first, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) + require.NoError(t, err) + + second, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) + require.NoError(t, err) + + firstKeypair, err := cbk.FromMnemonic(first) + 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) +} + func TestNewRejectsInvalidMnemonic(t *testing.T) { t.Parallel() @@ -105,99 +123,5 @@ func TestKeyAccessors(t *testing.T) { 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 + assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize) }