Implement pkg/cbk: crypto_box keypairs from BIP39 mnemonics

This commit is contained in:
2026-10-31 10:03:06 -07:00
parent 02386a6ff1
commit b7028fd7e3
2 changed files with 426 additions and 0 deletions
+223
View File
@@ -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
}