Narrow pkg/cbk to keypair derivation

Remove KeyFromHex, Seal, and Open with their errors and constants. The
library only derives crypto_box keypairs from BIP39 mnemonics; callers
use golang.org/x/crypto/nacl/box directly for everything else.
This commit is contained in:
2026-10-31 11:27:09 -07:00
parent 5932cc12ee
commit ea6faaecff
3 changed files with 35 additions and 227 deletions
+11 -29
View File
@@ -67,29 +67,14 @@ func main() {
} }
// The same mnemonic (and optional passphrase) always yields the same // The same mnemonic (and optional passphrase) always yields the same
// keypair, on any machine. // keypair, on any machine. The keys are plain crypto_box keys for use
alice, err := cbk.New(cbk.Params{Mnemonic: mnemonic}) // with golang.org/x/crypto/nacl/box.
keypair, err := cbk.New(cbk.Params{Mnemonic: mnemonic})
if err != nil { if err != nil {
panic(err) panic(err)
} }
bob, err := cbk.FromMnemonic("abandon abandon abandon abandon abandon " + fmt.Println(keypair.PublicKeyHex())
"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())
} }
``` ```
@@ -143,9 +128,9 @@ directory is never read.
### `pkg/cbk` ### `pkg/cbk`
Derives NaCl crypto_box keypairs (Curve25519, XSalsa20, Poly1305) Derives NaCl crypto_box keypairs (Curve25519) deterministically from BIP39
deterministically from BIP39 mnemonics, and seals and opens messages with them. mnemonics, and nothing else. The derivation is fixed and documented so keys are
The derivation is fixed and documented so keys are reproducible: reproducible:
1. The mnemonic is validated and canonicalized with 1. The mnemonic is validated and canonicalized with
`bip39wordlist.NormalizeWordlist`. `bip39wordlist.NormalizeWordlist`.
@@ -156,16 +141,13 @@ The derivation is fixed and documented so keys are reproducible:
`HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`. `HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`.
4. The public key is the Curve25519 base-point multiple of the private key. 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 The resulting keys are plain 32-byte crypto_box keys for use with
can be handled as a single opaque byte string. The API: `golang.org/x/crypto/nacl/box`. The API:
- `cbk.New(cbk.Params{Mnemonic, Passphrase})` and `cbk.FromMnemonic(m)` return a - `cbk.New(cbk.Params{Mnemonic, Passphrase})` and `cbk.FromMnemonic(m)` return a
`CryptoBoxKeypair`. `CryptoBoxKeypair`.
- `CryptoBoxKeypair.Seal(message, recipientPublicKey)` and - `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, and the `PublicKeyHex`,
`CryptoBoxKeypair.Open(sealed, senderPublicKey)`. `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors on the keypair.
- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, `cbk.KeyFromHex(s)`, and the
`PublicKeyHex`, `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors
on the keypair.
The test suite pins the keypair derived from the standard all-zero-entropy BIP39 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 test vector. If that test ever fails, the derivation has changed and existing
+4 -102
View File
@@ -1,8 +1,6 @@
// Package cbk derives NaCl crypto_box keypairs (Curve25519, XSalsa20, // Package cbk derives NaCl crypto_box keypairs (Curve25519) deterministically
// Poly1305) deterministically from BIP39 mnemonic phrases, and seals and // from BIP39 mnemonic phrases. That is all it does; the resulting keys are
// opens messages with them. // plain 32-byte crypto_box keys for use with golang.org/x/crypto/nacl/box.
//
// # Key derivation
// //
// The same mnemonic and passphrase always produce the same keypair: // 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. // HKDF-SHA256, using no salt and DerivationInfo as the info string.
// 4. The public key is the Curve25519 scalar multiplication of the // 4. The public key is the Curve25519 scalar multiplication of the
// private key with the base point, via golang.org/x/crypto/nacl/box. // 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 package cbk
import ( import (
"bytes" "bytes"
"crypto/hkdf" "crypto/hkdf"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"github.com/tyler-smith/go-bip39" "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 is the length in bytes of a crypto_box public or private key.
KeySize = 32 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 // DerivationInfo is the HKDF info string that binds derived keys to
// this package. Changing it changes every derived key. // this package. Changing it changes every derived key.
DerivationInfo = "sneak.berlin/go/x/pkg/cbk crypto_box v1" 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. // CryptoBoxKey is a 32-byte crypto_box key, either public or private.
type CryptoBoxKey [KeySize]byte 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. // Bytes returns a copy of the key as a byte slice.
func (k CryptoBoxKey) Bytes() []byte { func (k CryptoBoxKey) Bytes() []byte {
return k[:] return k[:]
@@ -151,7 +99,7 @@ func New(p Params) (CryptoBoxKeypair, error) {
// FromMnemonic derives the keypair for mnemonic with an empty passphrase. // FromMnemonic derives the keypair for mnemonic with an empty passphrase.
// It is shorthand for New(Params{Mnemonic: mnemonic}). // It is shorthand for New(Params{Mnemonic: mnemonic}).
func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) { 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. // 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 { func (kp CryptoBoxKeypair) PrivateKeyHex() string {
return kp.PrivateKey.Hex() 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
}
+20 -96
View File
@@ -6,7 +6,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/nacl/box"
"sneak.berlin/go/x/pkg/bip39wordlist" "sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk" "sneak.berlin/go/x/pkg/cbk"
@@ -83,6 +82,25 @@ func TestNewPassphraseChangesKeys(t *testing.T) {
assert.Equal(t, withExtraWord, again, "derivation is deterministic") 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) { func TestNewRejectsInvalidMnemonic(t *testing.T) {
t.Parallel() t.Parallel()
@@ -105,99 +123,5 @@ func TestKeyAccessors(t *testing.T) {
assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize) assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize)
assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex()) assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex())
assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex()) assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex())
} assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize)
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
} }