From fe6dbba428b8ea7f8f7e4e439aa424bc8cf85eac Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 31 Oct 2026 11:38:02 -0700 Subject: [PATCH] Derive cbk keys with BIP85 instead of an ad hoc HKDF label The private key is now the BIP85 HEX application output for 32 bytes at m/83696968'/128169'/32'/{index}' from the BIP32 master key of the BIP39 seed, via sneak.berlin/go/secret/pkg/bip85, so any BIP85 tool reproduces it and one mnemonic backs many keypairs through Params.Index. Tests check the mnemonic-to-master step and the HEX application against the BIP85 specification vectors and pin the resulting keypair. --- README.md | 26 ++++--- go.mod | 6 ++ go.sum | 105 +++++++++++++++++++++++++++++ pkg/cbk/cbk.go | 78 +++++++++++++++------ pkg/cbk/cbk_test.go | 161 +++++++++++++++++++++++++++++++++++--------- 5 files changed, 312 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 0cbb964..fe82d26 100644 --- a/README.md +++ b/README.md @@ -129,29 +129,35 @@ directory is never read. ### `pkg/cbk` Derives NaCl crypto_box keypairs (Curve25519) deterministically from BIP39 -mnemonics, and nothing else. The derivation is fixed and documented so keys are -reproducible: +mnemonics, and nothing else. The entropy comes from +[BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki) through +`sneak.berlin/go/secret/pkg/bip85`, so any BIP85-capable tool given the same +mnemonic reproduces the same key, and one mnemonic can back any number of +independent keypairs: 1. The mnemonic is validated and canonicalized with `bip39wordlist.NormalizeWordlist`. 2. The standard 64-byte BIP39 seed is computed with PBKDF2-HMAC-SHA512 (2048 rounds, salt `"mnemonic" + passphrase`), exactly as a BIP39 wallet would, so the optional passphrase works as the usual "25th word". -3. The 32-byte private key is - `HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`. -4. The public key is the Curve25519 base-point multiple of the private key. +3. The seed becomes a BIP32 master key. +4. BIP85's HEX application derives 32 bytes of entropy at + `m/83696968'/128169'/32'/{index}'`; those bytes are the private key. +5. The public key is the Curve25519 base-point multiple of the private key. 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`. +- `cbk.New(cbk.Params{Mnemonic, Passphrase, Index})` and `cbk.FromMnemonic(m)` + (no passphrase, index 0) return a `CryptoBoxKeypair`. - `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 -keys are no longer reproducible. +The test suite checks the mnemonic-to-master-key step and the BIP85 HEX +application against the BIP85 specification's own test vectors, verifies that +the private key is exactly the BIP85 output, and pins the keypair for the +specification's mnemonic at index 0. If the pinned test ever fails, the +derivation has changed and existing keys are no longer reproducible. ### `pkg/bip39wordlist` diff --git a/go.mod b/go.mod index 37c8646..06d7b3b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module sneak.berlin/go/x go 1.26.0 require ( + github.com/btcsuite/btcd v0.24.2 + github.com/btcsuite/btcd/btcutil v1.1.6 github.com/kavfixnel/words v1.0.0 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.10.2 @@ -10,9 +12,13 @@ require ( github.com/stretchr/testify v1.12.1 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/crypto v0.58.0 + sneak.berlin/go/secret v1.0.0-rc4 ) require ( + github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 900db4e..bf02bd9 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,88 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= +github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kavfixnel/words v1.0.0 h1:k7/KXUnLIVNCAlBLXhdq1GunnxXmQqglZooHA+NRzuQ= github.com/kavfixnel/words v1.0.0/go.mod h1:i2LITxcnsJnKfT+ruhPGVwzdcRDnsgQTL9TIooX9XAU= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -37,25 +101,66 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sneak.berlin/go/secret v1.0.0-rc4 h1:BFpPs34qhcYS2ZtK+nvWmBT7CGFgjYWY9KA9Amske3w= +sneak.berlin/go/secret v1.0.0-rc4/go.mod h1:pvhNbatHqGCO9l7hXYjlKEHkpGh7u8q/uE7mzP9EZXo= diff --git a/pkg/cbk/cbk.go b/pkg/cbk/cbk.go index 016a2ee..3e241c1 100644 --- a/pkg/cbk/cbk.go +++ b/pkg/cbk/cbk.go @@ -1,43 +1,56 @@ // 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. +// from BIP39 mnemonic phrases using BIP85. 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: +// The same mnemonic, passphrase, and index 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 +// rounds, salt "mnemonic" + passphrase), exactly as a wallet would. +// 3. The seed becomes a BIP32 master key. +// 4. BIP85 (sneak.berlin/go/secret/pkg/bip85) derives 32 bytes of +// application entropy from the master key with the HEX application, +// at path m/83696968'/128169'/32'/{index}'. Those bytes are the +// crypto_box private key. +// 5. The public key is the Curve25519 scalar multiplication of the // private key with the base point, via golang.org/x/crypto/nacl/box. +// +// Because the entropy comes from BIP85, any BIP85-capable tool given the +// same mnemonic reproduces the private key, and the index lets one +// mnemonic back any number of independent keypairs. package cbk import ( "bytes" - "crypto/hkdf" - "crypto/sha256" "encoding/hex" + "errors" "fmt" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/tyler-smith/go-bip39" "golang.org/x/crypto/nacl/box" + "sneak.berlin/go/secret/pkg/bip85" "sneak.berlin/go/x/pkg/bip39wordlist" ) const ( // KeySize is the length in bytes of a crypto_box public or private key. + // It is also the byte count requested from the BIP85 HEX application. KeySize = 32 - // 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" + // MaxIndex is the largest valid Params.Index. BIP85 path components + // are hardened BIP32 child indexes, which leaves 31 bits. + MaxIndex = hdkeychain.HardenedKeyStart - 1 ) +// ErrIndexTooLarge is returned by New when Params.Index exceeds MaxIndex. +var ErrIndexTooLarge = errors.New("cbk: index exceeds the BIP32 hardened range") + // CryptoBoxKey is a 32-byte crypto_box key, either public or private. type CryptoBoxKey [KeySize]byte @@ -61,6 +74,11 @@ type Params struct { // "25th word"). It is used verbatim: it is case sensitive and is not // trimmed. An empty passphrase is the common case. Passphrase string + + // Index selects which of the keypairs derivable from the mnemonic to + // return. It is the final component of the BIP85 derivation path, so + // each index yields an independent keypair. Zero is the first. + Index uint32 } // CryptoBoxKeypair is a crypto_box public and private key pair. @@ -69,20 +87,36 @@ type CryptoBoxKeypair struct { 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 +// New derives the keypair for the mnemonic, passphrase, and index 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) { + if p.Index > MaxIndex { + return CryptoBoxKeypair{}, fmt.Errorf("%w: %d", ErrIndexTooLarge, p.Index) + } + 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) + // The network parameters only choose the version bytes used when a + // key is serialized; they do not affect derivation. + masterKey, err := hdkeychain.NewMaster( + bip39.NewSeed(mnemonic, p.Passphrase), &chaincfg.MainNetParams, + ) if err != nil { - return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving private key: %w", err) + return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP32 master key: %w", err) + } + + privateKeyHex, err := bip85.DeriveHex(masterKey, KeySize, p.Index) + if err != nil { + return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP85 entropy: %w", err) + } + + privateKeyBytes, err := hex.DecodeString(privateKeyHex) + if err != nil { + return CryptoBoxKeypair{}, fmt.Errorf("cbk: decoding BIP85 entropy: %w", err) } publicKey, privateKey, err := box.GenerateKey(bytes.NewReader(privateKeyBytes)) @@ -96,10 +130,10 @@ func New(p Params) (CryptoBoxKeypair, error) { }, nil } -// FromMnemonic derives the keypair for mnemonic with an empty passphrase. -// It is shorthand for New(Params{Mnemonic: mnemonic}). +// FromMnemonic derives the keypair for mnemonic with an empty passphrase +// at index zero. It is shorthand for New(Params{Mnemonic: mnemonic}). func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) { - return New(Params{Mnemonic: mnemonic, Passphrase: ""}) + return New(Params{Mnemonic: mnemonic, Passphrase: "", Index: 0}) } // PublicKeyBytes returns a copy of the public key as a byte slice. diff --git a/pkg/cbk/cbk_test.go b/pkg/cbk/cbk_test.go index c109616..c4ec30a 100644 --- a/pkg/cbk/cbk_test.go +++ b/pkg/cbk/cbk_test.go @@ -4,69 +4,152 @@ import ( "strings" "testing" + "github.com/btcsuite/btcd/btcutil/hdkeychain" + "github.com/btcsuite/btcd/chaincfg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/secret/pkg/bip85" "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. +// The BIP85 specification publishes its test vectors against one master +// key. specMnemonic is the BIP39 phrase that produces that master key with +// an empty passphrase, so the whole mnemonic-to-entropy chain this package +// relies on can be checked against the specification. const ( - vectorPublicKeyHex = "db9c1ec248805579d0b0fe2d5a4814f96009f66a11c91260487b79167f881b20" + specMnemonic = "install scatter logic circle pencil average fall shoe " + + "quantum disease suspect usage" - vectorPrivateKeyHex = "eec3d5da4e348bd16df93b61c595bb5f75645f9aa468e72513be033a72b3d053" + specMasterKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8" + + "kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" + + // specHex64 is the specification's HEX application vector for + // m/83696968'/128169'/64'/0'. + specHex64 = "492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e7" + + "1aa2d645442f878555d078fd1f1f67e368976f04137b1f7a" + + "0d19232136ca50c44614af72b5582a5c" ) -// vectorMnemonic returns the standard BIP39 test vector for all-zero -// 128-bit entropy. -func vectorMnemonic() string { - return strings.Repeat("abandon ", vectorRepeats) + "about" +// The keys below are the keypair for specMnemonic at index 0, pinned when +// the BIP85 derivation was implemented. If this test starts failing, the +// derivation has changed and existing keys are no longer reproducible. +const ( + vectorPublicKeyHex = "dbef711b3f340d336eb99cd8d0dbd491e1edce8eaad71cd66be228032a9a743e" + + vectorPrivateKeyHex = "ea3ceb0b02ee8e587779c63f4b7b3a21e950a213f1ec53cab608d13e8796e6dc" +) + +func TestSpecMnemonicProducesSpecMasterKey(t *testing.T) { + t.Parallel() + + masterKey := specMasterKeyFromMnemonic(t) + assert.Equal(t, specMasterKey, masterKey.String()) + + hex64, err := bip85.DeriveHex(masterKey, 64, 0) + require.NoError(t, err) + assert.Equal(t, specHex64, hex64) } func TestNewMatchesPinnedVector(t *testing.T) { t.Parallel() - keypair, err := cbk.FromMnemonic(vectorMnemonic()) + keypair, err := cbk.FromMnemonic(specMnemonic) require.NoError(t, err) assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex()) assert.Equal(t, vectorPrivateKeyHex, keypair.PrivateKeyHex()) } +func TestPrivateKeyIsBIP85HexApplicationOutput(t *testing.T) { + t.Parallel() + + masterKey := specMasterKeyFromMnemonic(t) + + for _, index := range []uint32{0, 1, 7} { + expected, err := bip85.DeriveHex(masterKey, cbk.KeySize, index) + require.NoError(t, err) + + keypair, err := cbk.New(cbk.Params{ + Mnemonic: specMnemonic, + Passphrase: "", + Index: index, + }) + require.NoError(t, err) + + assert.Equal(t, expected, keypair.PrivateKeyHex(), "index %d", index) + } +} + +func TestNewIndexSelectsIndependentKeypairs(t *testing.T) { + t.Parallel() + + first, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Passphrase: "", Index: 0}) + require.NoError(t, err) + + second, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Passphrase: "", Index: 1}) + require.NoError(t, err) + + assert.NotEqual(t, first.PublicKey, second.PublicKey) + assert.NotEqual(t, first.PrivateKey, second.PrivateKey) + + fromMnemonic, err := cbk.FromMnemonic(specMnemonic) + require.NoError(t, err) + assert.Equal(t, first, fromMnemonic, "FromMnemonic uses index 0") +} + +func TestNewRejectsIndexBeyondHardenedRange(t *testing.T) { + t.Parallel() + + _, err := cbk.New(cbk.Params{ + Mnemonic: specMnemonic, + Passphrase: "", + Index: cbk.MaxIndex + 1, + }) + require.ErrorIs(t, err, cbk.ErrIndexTooLarge) + + _, err = cbk.New(cbk.Params{ + Mnemonic: specMnemonic, + Passphrase: "", + Index: cbk.MaxIndex, + }) + require.NoError(t, err) +} + func TestNewNormalizesMnemonic(t *testing.T) { t.Parallel() - canonical, err := cbk.FromMnemonic(vectorMnemonic()) + canonical, err := cbk.FromMnemonic(specMnemonic) require.NoError(t, err) - messy, err := cbk.New(cbk.Params{ - Mnemonic: " ABANDON\tAbandon " + strings.Repeat("abandon ", 9) + - "\nabout\n", + words := strings.Fields(specMnemonic) + messy := " " + strings.ToUpper(words[0]) + "\t" + + strings.Join(words[1:], " ") + "\n" + + normalized, err := cbk.New(cbk.Params{ + Mnemonic: messy, Passphrase: "", + Index: 0, }) require.NoError(t, err) - assert.Equal(t, canonical, messy) + assert.Equal(t, canonical, normalized) } func TestNewPassphraseChangesKeys(t *testing.T) { t.Parallel() - plain, err := cbk.FromMnemonic(vectorMnemonic()) + plain, err := cbk.FromMnemonic(specMnemonic) require.NoError(t, err) extraWord := "TREZOR" withExtraWord, err := cbk.New(cbk.Params{ - Mnemonic: vectorMnemonic(), + Mnemonic: specMnemonic, Passphrase: extraWord, + Index: 0, }) require.NoError(t, err) @@ -74,8 +157,9 @@ func TestNewPassphraseChangesKeys(t *testing.T) { assert.NotEqual(t, plain.PrivateKey, withExtraWord.PrivateKey) again, err := cbk.New(cbk.Params{ - Mnemonic: vectorMnemonic(), + Mnemonic: specMnemonic, Passphrase: extraWord, + Index: 0, }) require.NoError(t, err) @@ -85,20 +169,17 @@ func TestNewPassphraseChangesKeys(t *testing.T) { func TestNewDifferentMnemonicsGiveDifferentKeys(t *testing.T) { t.Parallel() - first, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) + other, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) require.NoError(t, err) - second, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) + specKeypair, err := cbk.FromMnemonic(specMnemonic) require.NoError(t, err) - firstKeypair, err := cbk.FromMnemonic(first) + otherKeypair, err := cbk.FromMnemonic(other) 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) + assert.NotEqual(t, specKeypair.PublicKey, otherKeypair.PublicKey) + assert.NotEqual(t, specKeypair.PrivateKey, otherKeypair.PrivateKey) } func TestNewRejectsInvalidMnemonic(t *testing.T) { @@ -107,14 +188,17 @@ func TestNewRejectsInvalidMnemonic(t *testing.T) { _, err := cbk.FromMnemonic("abandon bogus about") require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) - _, err = cbk.FromMnemonic(strings.Repeat("abandon ", vectorRepeats) + "bogus") + words := strings.Fields(specMnemonic) + words[len(words)-1] = "bogus" + + _, err = cbk.FromMnemonic(strings.Join(words, " ")) require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord) } func TestKeyAccessors(t *testing.T) { t.Parallel() - keypair, err := cbk.FromMnemonic(vectorMnemonic()) + keypair, err := cbk.FromMnemonic(specMnemonic) require.NoError(t, err) assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes()) @@ -125,3 +209,16 @@ func TestKeyAccessors(t *testing.T) { assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex()) assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize) } + +// specMasterKeyFromMnemonic builds the BIP32 master key from specMnemonic +// the same way the package does, independently of it. +func specMasterKeyFromMnemonic(t *testing.T) *hdkeychain.ExtendedKey { + t.Helper() + + seed := bip39.NewSeed(specMnemonic, "") + + masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + require.NoError(t, err) + + return masterKey +}