diff --git a/go.mod b/go.mod index fcacd81..7799718 100644 --- a/go.mod +++ b/go.mod @@ -1,50 +1,41 @@ module github.com/luxfi/keys -go 1.26.1 +go 1.26.4 require ( github.com/luxfi/address v1.0.1 - github.com/luxfi/constants v1.4.3 - github.com/luxfi/crypto v1.17.39 - github.com/luxfi/go-bip32 v1.0.2 - github.com/luxfi/go-bip39 v1.1.2 - github.com/luxfi/ids v1.2.9 + github.com/luxfi/constants v1.5.8 + github.com/luxfi/crypto v1.20.0 + github.com/luxfi/go-bip32 v1.1.0 + github.com/luxfi/go-bip39 v1.2.0 + github.com/luxfi/ids v1.3.0 github.com/luxfi/protocol v0.0.2 github.com/luxfi/tls v1.0.3 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.52.0 ) require ( - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // indirect - github.com/beorn7/perks v1.0.1 // indirect github.com/btcsuite/btcd/btcutil v1.1.6 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/gorilla/rpc v1.2.1 // indirect github.com/holiman/uint256 v1.3.2 // indirect - github.com/luxfi/cache v1.2.0 // indirect - github.com/luxfi/consensus v1.22.53 // indirect + github.com/luxfi/accel v1.2.4 // indirect + github.com/luxfi/cache v1.2.1 // indirect github.com/luxfi/container v0.0.4 // indirect github.com/luxfi/formatting v1.0.1 // indirect - github.com/luxfi/geth v1.16.69 // indirect - github.com/luxfi/math v1.2.3 // indirect + github.com/luxfi/geth v1.16.98 // indirect + github.com/luxfi/math v1.4.1 // indirect github.com/luxfi/math/big v0.1.0 // indirect - github.com/luxfi/metric v1.4.10 // indirect - github.com/luxfi/mock v0.1.0 // indirect - github.com/luxfi/sampler v1.0.0 // indirect - github.com/luxfi/vm v1.0.16 // indirect + github.com/luxfi/metric v1.5.7 // indirect + github.com/luxfi/mock v0.1.1 // indirect + github.com/luxfi/sampler v1.1.0 // indirect + github.com/luxfi/vm v1.2.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect github.com/supranational/blst v0.3.16 // indirect go.uber.org/mock v0.6.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/sys v0.41.0 // indirect - gonum.org/v1/gonum v0.16.0 // indirect + golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/sys v0.45.0 // indirect + gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 4f7524b..a7f7578 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,4 @@ -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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= @@ -26,8 +22,6 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -39,8 +33,8 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= 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= @@ -67,56 +61,46 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M 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/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ= +github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ= github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g= github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0= -github.com/luxfi/cache v1.2.0 h1:VEk/ue13oi8w3sKBB/Dsv9fnjmfdXgb0NMibcPLVbss= -github.com/luxfi/cache v1.2.0/go.mod h1:Esn48WMB3JL/+UOdCEV2Chyjr1MG3SSckXWLhB/P080= -github.com/luxfi/consensus v1.22.53 h1:iwrnImZJ7Ur6oV7rx0WSrR7n3X92pdnOX3mivCBCuZ4= -github.com/luxfi/consensus v1.22.53/go.mod h1:GicEGtNrPPG6063k/aRFusv87e17i0UwmgeY4D8Vn6s= -github.com/luxfi/constants v1.4.3 h1:bcqUO8twMOhHWCgN/RbjfWulVPbvUQcxDkssaRrCx4g= -github.com/luxfi/constants v1.4.3/go.mod h1:ENkJ121cmDEkwQPDiKK4QhnTnW9u37PGpepbrdVcAmc= +github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI= +github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU= +github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g= +github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48= github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM= github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek= -github.com/luxfi/crypto v1.17.39 h1:dDmktYOD/sU6WjIpitIfuHp7mRbc3izOsyJrQ1c5eOQ= -github.com/luxfi/crypto v1.17.39/go.mod h1:mChLWmW4CLR1wAN6CeJTveCzUv0DTzGQnYgq01x3W0U= +github.com/luxfi/crypto v1.20.0 h1:JNsQ25sVO6T8XuIHRue4akOpnt5pNmk1xg5hzmU6dNE= +github.com/luxfi/crypto v1.20.0/go.mod h1:bLCBuIV/KDjPytld7jSYe1WbfWknPQXcivq88Qo96QU= github.com/luxfi/formatting v1.0.1 h1:ZnE1rAdEUds9yAegdVdGDOBGN6hLMPOv6E03Fp8IEYo= github.com/luxfi/formatting v1.0.1/go.mod h1:mYzNf5DJOiqSSKUPzNj5dKy4tstFbN3pZlkI5716eKc= -github.com/luxfi/geth v1.16.69 h1:CHO6xTZ+A+3itk94ts4uyVRJajNVP3RxWTjJp5qGOlk= -github.com/luxfi/geth v1.16.69/go.mod h1:8eEO1hW5sa6OH2VeCMaCPnRz28JBxFvCPBCWPLsU2ck= -github.com/luxfi/go-bip32 v1.0.2 h1:7vFbb+Wr4Z499q2tuCLdd7wWjtn8sH+HWBlx76mhH9Y= -github.com/luxfi/go-bip32 v1.0.2/go.mod h1:bc7/LXDKAJQZ/F0Xjf5yXaTZxY9/ssLb4FC+Hxn/cDk= -github.com/luxfi/go-bip39 v1.1.2 h1:p+wLMPGs6MLQh7q0YIsmy2EhHL7LHiELEGTJko6t/Jg= -github.com/luxfi/go-bip39 v1.1.2/go.mod h1:96de9VkR2kY/ASAnhMtvt3TSh+PZkAFAngNj0GjRGDo= -github.com/luxfi/ids v1.2.9 h1:+yjdhXW99drnd2Zlp1u/p8k3G23W3/1btJQ4ogHawUI= -github.com/luxfi/ids v1.2.9/go.mod h1:khJOEdOPxd22yn0jcVrnbX1ADa0GHn5Y74gvCzN5BYc= -github.com/luxfi/math v1.2.3 h1:BgvIFw/srPXFLbcqtoDhLJOfmBsn86GPA1iWgsoyUb4= -github.com/luxfi/math v1.2.3/go.mod h1:C8STnF2H+D6rqBPt248CiWY2TGuJgdtv/+4UqrT15iM= +github.com/luxfi/geth v1.16.98 h1:w187TtKuGStf3tm2bshuHVKBv2Frjx0lT54kQVXyNHA= +github.com/luxfi/geth v1.16.98/go.mod h1:6kEzSExdk9CPQDPXALt6P3HfQqBq7KF1Jrrv9gBpxbU= +github.com/luxfi/go-bip32 v1.1.0 h1:zjy19WKa1KJnGamRNyOZM3l/MPtV/sax7M4NMPwLHZQ= +github.com/luxfi/go-bip32 v1.1.0/go.mod h1:QyDXlzWL3xRiCbMUi4Z3J52Q/SMoCvdRLXXlYvSZor8= +github.com/luxfi/go-bip39 v1.2.0 h1:fx3pFuSGawCG4In6pA4OLLStqbgIqD1j8EygFskoHzY= +github.com/luxfi/go-bip39 v1.2.0/go.mod h1:if+2OVbG4k4jKIuBt/Rse1KV1kgWQM5j5xFbUtwbNtc= +github.com/luxfi/ids v1.3.0 h1:11xnwRDm6zQzbqcRnkFujOYkvhK4Fs/+g+sKRlRUNsU= +github.com/luxfi/ids v1.3.0/go.mod h1:6vpdcdZW0qxeade+3xby8aLTutbcJ7O0r8+fNQrksGI= +github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q= +github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA= github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ= github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk= -github.com/luxfi/metric v1.4.10 h1:uWeLupMLxK7YK1c99gY23cT+hornWg7OmZgHNK4HNbw= -github.com/luxfi/metric v1.4.10/go.mod h1:3OIQ+e6mciesm3cik3Q4CSOAvTlBy4JA0ZoKzCB9APo= -github.com/luxfi/mock v0.1.0 h1:IwElfNu+T9sXvzFX6tudPDx1vqPuACRSRdxpD5lxW+o= -github.com/luxfi/mock v0.1.0/go.mod h1:izF+9K0gGzFC9zERn6Po37v46eLdPB+EIsDjL3GLk+U= +github.com/luxfi/metric v1.5.7 h1:LoSPEUpak2SLcynF+LT2cXjl9ECp4nY+Lia9zudmDv4= +github.com/luxfi/metric v1.5.7/go.mod h1:CMguEhyuLi4YUWyXimJ+UHply99BDFrL0pxedB7rBqM= +github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY= +github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU= github.com/luxfi/protocol v0.0.2 h1:TAuXMm1K4VDpG3B3brq1EE9Lb7XlbhQmVBObskgNhmc= github.com/luxfi/protocol v0.0.2/go.mod h1:Yjn2VRwn5PXim0+JTalAyrVG6YbmuSnYOfeUXSbAsqA= -github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0= -github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk= +github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk= +github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs= github.com/luxfi/tls v1.0.3 h1:rK3nxSAxrUOOSHOZnKChwV4f6UJ+cfOl8KWJXAQx/SI= github.com/luxfi/tls v1.0.3/go.mod h1:dQqSiGE7YxXUxOwICoReUuIitBms9DYOaCeteBwmIWw= -github.com/luxfi/vm v1.0.16 h1:IOTuMZUAKJ5i40GcWHs9FqhnC4Dd/j2Rq34kdPPYb1U= -github.com/luxfi/vm v1.0.16/go.mod h1:WVRRGWk2xbCDEMfsYmTrDIo839O1eIInlR8JT+BhZgo= +github.com/luxfi/vm v1.2.0 h1:jTwQRHdC9VmyRZTPSn+IqjMju7f6xlxLc6P9CEg+Y2M= +github.com/luxfi/vm v1.2.0/go.mod h1:qasVIBRerVQuvy9vFGrX3H8X8pPMPG5un/KbZSyq5YY= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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= @@ -129,16 +113,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= @@ -151,19 +125,15 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= -golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= 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= @@ -179,16 +149,16 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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= @@ -198,8 +168,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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= diff --git a/hybrid.go b/hybrid.go new file mode 100644 index 0000000..aac8175 --- /dev/null +++ b/hybrid.go @@ -0,0 +1,355 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// hybrid.go — Bindel-Brendel-Fischlin (CCS 2021) + Cremers-Düzlü-Fiedler- +// Fischlin-Janson (Asiacrypt 2023) stronger-binding hybrid signature +// scheme for Lux validator identity. +// +// Construction (BBF21 N-Sig with CDFFJ23 joint-pk binding): +// +// m_bound = H_bind(pk_c, pk_pq, msg) +// = SHAKE256-384( "lux-hybrid-sig-v1" +// || left_encode(8·|pk_c|) || pk_c +// || left_encode(8·|pk_pq|) || pk_pq +// || left_encode(8·|msg|) || msg ) +// sig_c = secp256k1.SignHash(sk_c, m_bound) +// sig_pq = mldsa65.SignCtx(sk_pq, m_bound, "lux-hybrid-sig-v1") +// Verify = AND( secp256k1.VerifyHash(pk_c, m_bound, sig_c), +// mldsa65.VerifyCtx(pk_pq, m_bound, sig_pq, "lux-hybrid-sig-v1") ) +// +// Why this construction (vs raw concat): +// +// Raw concat `pk_c || pk_pq` and per-component signatures over `msg` +// reduces to MIN security under a non-honest-sign adversary +// (CDFFJ23 §4): an adversary that registers a malformed pk_pq can +// force the hybrid to drop to classical security only. By hashing +// BOTH pubkeys into m_bound, every signature is *cryptographically +// bound to the joint identity* — substituting either component +// invalidates the binding. This is BBF21 §3.2 Cons. 2 "Combined +// Concatenated" with the CDFFJ23 strengthening: m_bound includes +// both pubkeys so component swap is detectable. +// +// Why secp256k1 as classical (not ed25519 or X25519): +// +// (a) X25519 is a KEM, not a signature scheme — disqualified. +// (b) Lux P/X validator identity already anchors on secp256k1 +// (see keys.ValidatorKey.ECPrivateKey + .PChainAddr). Picking +// secp256k1 means the hybrid is a pure superset — every existing +// validator's classical key continues to play the same role. +// (c) ed25519 would add a third primitive with no existing +// infrastructure: no NodeID derivation, no stake record format. +// secp256k1 is the only choice that preserves the one-and-only-one +// way of identifying a P/X validator. +// +// Security claim (BBF21 Thm. 1 + CDFFJ23 Thm. 3 corollary): +// +// The scheme is EUF-CMA under +// max( EUF-CMA_secp256k1, sEUF-CMA_MLDSA65 ) +// in the chosen-message model where the adversary may submit +// adversarial pk_pq's during registration. This is strictly +// stronger than min(·, ·) which is all raw concatenation buys. +// +// Domain separation: +// +// The string "lux-hybrid-sig-v1" appears verbatim TWICE: once as +// the prefix of m_bound (binds the hash), once as the ML-DSA-65 +// context (binds the PQ signature). This is intentional — a future +// v2 of the scheme (say, with ML-DSA-87 or different prefix bytes) +// would replace both occurrences atomically; no cross-version +// replay is possible. + +package keys + +import ( + "crypto/rand" + "errors" + "fmt" + "io" + + mldsa "github.com/luxfi/crypto/mldsa" + secp "github.com/luxfi/crypto/secp256k1" + "golang.org/x/crypto/sha3" +) + +// HybridSigDomain is the canonical domain-separation string for the +// Lux hybrid signature scheme. Pinned at v1; bumping invalidates every +// prior hybrid signature, which is the correct behaviour for a hardfork +// of the binding encoding. The same string is used both as the H_bind +// prefix and as the ML-DSA-65 context. +const HybridSigDomain = "lux-hybrid-sig-v1" + +// HybridBoundDigestLen is the byte length of the m_bound digest the +// scheme uses internally. 48 bytes = SHAKE256-384, the same hash size +// as ids.FullDigest — matches the Lux convention for identity digests. +const HybridBoundDigestLen = 48 + +// Typed errors. errors.Is friendly. +var ( + // ErrHybridNilKey — either component of the hybrid key is nil. + // Refused early; we never want a half-hybrid signing path. + ErrHybridNilKey = errors.New("keys: hybrid key has nil component") + + // ErrHybridClassicalSign — secp256k1 signing failed. The error + // wraps the underlying secp256k1 error. + ErrHybridClassicalSign = errors.New("keys: hybrid classical sign failed") + + // ErrHybridPQSign — ML-DSA-65 signing failed. The error wraps + // the underlying mldsa error. + ErrHybridPQSign = errors.New("keys: hybrid PQ sign failed") + + // ErrHybridClassicalVerify — secp256k1 verification failed. + // A valid hybrid signature requires BOTH components to verify. + ErrHybridClassicalVerify = errors.New("keys: hybrid classical verification failed") + + // ErrHybridPQVerify — ML-DSA-65 verification failed. A valid + // hybrid signature requires BOTH components to verify. + ErrHybridPQVerify = errors.New("keys: hybrid PQ verification failed") + + // ErrHybridNilSig — signature struct or one of its components is + // nil/empty. Refused before any expensive verification. + ErrHybridNilSig = errors.New("keys: hybrid signature has nil component") +) + +// HybridPublicKey is the joint pubkey of the BBF-bound hybrid scheme. +// Both components are required for any verification; a half-hybrid is +// a programmer error. +type HybridPublicKey struct { + // Classical is the secp256k1 verification key. 33-byte compressed + // SEC1 encoding on the wire. This is the same primitive the + // existing Lux validator set uses for P/X identity. + Classical *secp.PublicKey + + // PQ is the ML-DSA-65 verification key (FIPS 204). Bytes-on-the- + // wire form is the standard ML-DSA-65 public key encoding. + PQ *mldsa.PublicKey +} + +// HybridPrivateKey is the joint signing key of the BBF-bound hybrid +// scheme. Owns both private components; the only legal use is internal +// Sign(). Call Wipe() when done. +type HybridPrivateKey struct { + // Classical is the secp256k1 signing key (32 bytes scalar). + Classical *secp.PrivateKey + + // PQ is the ML-DSA-65 signing key (FIPS 204). + PQ *mldsa.PrivateKey +} + +// HybridSignature is the joint signature of the BBF-bound hybrid +// scheme. BOTH components are required for verification. Neither is +// independently useful — that is the binding the scheme provides. +type HybridSignature struct { + // Classical is the secp256k1 recoverable signature (65 bytes). + Classical []byte + + // PQ is the ML-DSA-65 signature (FIPS 204 standard size). + PQ []byte +} + +// HybridPublic extracts the joint public key from the joint private +// key. Pure function: no allocation other than the returned struct. +func (sk *HybridPrivateKey) Public() *HybridPublicKey { + if sk == nil { + return nil + } + return &HybridPublicKey{ + Classical: sk.Classical.PublicKey(), + PQ: sk.PQ.PublicKey, + } +} + +// Wipe zeroes both private components in place. Idempotent. Safe to +// call from a defer on a nil receiver. +func (sk *HybridPrivateKey) Wipe() { + if sk == nil { + return + } + if sk.PQ != nil { + sk.PQ.Zeroize() + sk.PQ = nil + } + // secp256k1.PrivateKey holds key bytes in an unexported field; + // the canonical wipe is to drop the pointer reference and let the + // GC reclaim — luxfi/crypto/secp256k1 does not export a Zeroize + // (the scalar is consumed by SignHash internally and the bytes + // live in the heap). Dropping the reference is the best we can + // portably do without forking the upstream API. + sk.Classical = nil +} + +// boundDigest computes m_bound — the canonical SHAKE256-384 commitment +// the scheme binds. This is the joint message every signature is +// computed over and every verifier reconstructs. +// +// Wire shape (SP 800-185 left_encode framed): +// +// left_encode(8·|domain|) || domain (= HybridSigDomain) +// left_encode(8·|pk_classical|) || pk_classical (33 bytes SEC1) +// left_encode(8·|pk_pq|) || pk_pq (ML-DSA-65 pubkey) +// left_encode(8·|msg|) || msg +// +// 48-byte SHAKE256-384 output. The encoding is unambiguous: an +// adversary cannot smuggle field bytes across boundaries because each +// field's length is prepended. +func boundDigest(pk *HybridPublicKey, msg []byte) ([]byte, error) { + if pk == nil || pk.Classical == nil || pk.PQ == nil { + return nil, ErrHybridNilKey + } + pkClassical := pk.Classical.CompressedBytes() // 33 bytes + pkPQ := pk.PQ.Bytes() // ML-DSA-65 pubkey bytes + + h := sha3.NewShake256() + _, _ = h.Write(leftEncode(uint64(len(HybridSigDomain)) * 8)) + _, _ = h.Write([]byte(HybridSigDomain)) + _, _ = h.Write(leftEncode(uint64(len(pkClassical)) * 8)) + _, _ = h.Write(pkClassical) + _, _ = h.Write(leftEncode(uint64(len(pkPQ)) * 8)) + _, _ = h.Write(pkPQ) + _, _ = h.Write(leftEncode(uint64(len(msg)) * 8)) + _, _ = h.Write(msg) + + out := make([]byte, HybridBoundDigestLen) + _, _ = h.Read(out) + return out, nil +} + +// HybridBoundDigest is the exported helper computing m_bound. Same +// algorithm as the internal boundDigest; exported so an out-of-band +// verifier (e.g. an on-chain precompile or a different language's +// implementation) can reconstruct the bound message verbatim. +// +// Pure function: no I/O, no randomness. +func HybridBoundDigest(pk *HybridPublicKey, msg []byte) ([]byte, error) { + return boundDigest(pk, msg) +} + +// HybridSign produces a BBF-bound joint signature over msg under the +// given joint signing key. The classical signature uses secp256k1 +// SignHash over m_bound; the PQ signature uses ML-DSA-65 SignCtx with +// HybridSigDomain as the context. +// +// Why two domain-separated bindings: +// +// (1) m_bound's prefix binds the SHAKE256 input to this scheme. +// (2) The ML-DSA context binds the FIPS 204 signature itself to +// this scheme — preventing cross-protocol replay of a future +// hybrid signature against any other ML-DSA-65 surface (KMS +// envelope, P-Chain block, etc.). +// +// rand is the randomness source for ML-DSA-65 hedged signing. If nil, +// crypto/rand is used. The secp256k1 signature uses RFC 6979 +// deterministic-k — no randomness needed. +func HybridSign(sk *HybridPrivateKey, msg []byte, randSource io.Reader) (*HybridSignature, error) { + if sk == nil || sk.Classical == nil || sk.PQ == nil { + return nil, ErrHybridNilKey + } + if randSource == nil { + randSource = rand.Reader + } + + pk := sk.Public() + mBound, err := boundDigest(pk, msg) + if err != nil { + return nil, err + } + + // Classical: secp256k1 SignHash(m_bound). SignHash expects a + // 32-byte hash, but m_bound is 48 bytes (SHAKE256-384). The + // canonical thing to do is sign the first 32 bytes of the digest + // — keep the full SHAKE256-384 output for the PQ side (which + // can absorb arbitrary length via SignCtx), and use the prefix + // for the classical side which requires a 32-byte hash. + // + // This is sound: the SHAKE256-384 output is uniform random under + // the random-oracle assumption, so its 32-byte prefix is also + // uniform random — exactly what secp256k1 SignHash expects. + sigC, err := sk.Classical.SignHash(mBound[:32]) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrHybridClassicalSign, err) + } + + // PQ: ML-DSA-65 SignCtx with full 48-byte m_bound + domain + // context. FIPS 204 hedged signing reads randomness from + // randSource. SignCtx absorbs the full digest. + sigPQ, err := sk.PQ.SignCtx(randSource, mBound, []byte(HybridSigDomain)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrHybridPQSign, err) + } + + return &HybridSignature{ + Classical: sigC, + PQ: sigPQ, + }, nil +} + +// HybridVerify checks a BBF-bound joint signature. Returns nil on +// success; returns a typed error identifying which component failed +// (or ErrHybridNilKey/ErrHybridNilSig for input errors). +// +// BOTH components MUST verify. This is the AND-mode binding: a +// signature where only one component verifies is a forgery (or a +// substitution attempt) and MUST be refused. +// +// The verification is order-independent: classical-first matches the +// natural reading of the construction, but a verifier could equally +// check PQ first. Either way, BOTH must pass. +func HybridVerify(pk *HybridPublicKey, msg []byte, sig *HybridSignature) error { + if pk == nil || pk.Classical == nil || pk.PQ == nil { + return ErrHybridNilKey + } + if sig == nil || len(sig.Classical) == 0 || len(sig.PQ) == 0 { + return ErrHybridNilSig + } + + mBound, err := boundDigest(pk, msg) + if err != nil { + return err + } + + // Classical: secp256k1 VerifyHash over m_bound[:32]. Mirrors the + // signing path's 32-byte prefix choice. + if !pk.Classical.VerifyHash(mBound[:32], sig.Classical) { + return ErrHybridClassicalVerify + } + + // PQ: ML-DSA-65 VerifySignatureCtx over full m_bound + domain + // context. Mirrors the signing-side SignCtx call. + if !pk.PQ.VerifySignatureCtx(mBound, sig.PQ, []byte(HybridSigDomain)) { + return ErrHybridPQVerify + } + + return nil +} + +// HybridPublicKeyBytes returns the canonical wire encoding of the +// joint public key: +// +// left_encode(8·|pk_classical|) || pk_classical +// left_encode(8·|pk_pq|) || pk_pq +// +// This is the same framing m_bound uses for the joint pubkey, minus +// the domain prefix and msg. The encoding is unambiguous and can be +// reversed (each field's length is recoverable from its left_encode +// header). +// +// Used to compute a hybrid NodeID via the existing ids.NodeIDScheme +// derivation — the scheme produces NodeID = SHAKE256-384("NODE_ID_V1" +// || chainID || scheme_byte || pubkey)[:20], so passing the wire-form +// hybrid pubkey here yields a NodeID committed to BOTH components. +func HybridPublicKeyBytes(pk *HybridPublicKey) ([]byte, error) { + if pk == nil || pk.Classical == nil || pk.PQ == nil { + return nil, ErrHybridNilKey + } + pkClassical := pk.Classical.CompressedBytes() + pkPQ := pk.PQ.Bytes() + + // Pre-size: header bytes for two left_encodes (<= 9 each) plus + // the two field payloads. The actual headers are typically 2-3 + // bytes, but worst-case sizing keeps the allocation single. + out := make([]byte, 0, 18+len(pkClassical)+len(pkPQ)) + out = append(out, leftEncode(uint64(len(pkClassical))*8)...) + out = append(out, pkClassical...) + out = append(out, leftEncode(uint64(len(pkPQ))*8)...) + out = append(out, pkPQ...) + return out, nil +} diff --git a/service_identity.go b/service_identity.go new file mode 100644 index 0000000..85d014e --- /dev/null +++ b/service_identity.go @@ -0,0 +1,644 @@ +// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// service_identity.go — mnemonic-derived service identity for in-cluster auth. +// +// Every Lux-derived service (kms-operator, hanzo-paas, hanzo-base, +// hanzo-commerce, …) authenticates to its peers by producing a signed +// envelope. Identity is derived from a BIP-39 mnemonic plus a stable +// servicePath — same mnemonic + path → same NodeID byte-for-byte across +// pods, reboots, machines. +// +// Pure-function derivation: +// +// seed = BIP-39 PBKDF2(mnemonic, "") +// master = BIP-32 master(seed) +// hardenedi = master · m/44'/9000'/serviceIndex'/0'/0' +// signing = ML-DSA-65(seed = SHAKE256(hardened-key || "lux-svc-mldsa-v1")) +// NodeID = SHAKE256-384("NODE_ID_V1" || chainID || 0x42 || pub)[:20] +// +// 9000 is the SLIP-0044 coin-type for Lux P/X (same value the wallet +// uses for staking and platform-chain HD derivation — see ~/work/lux/wallet +// /apps/web/src/lib/derive.ts and ~/work/lux/cli/cmd/keycmd). 0x42 is the +// canonical ML-DSA-65 NodeID scheme byte from luxfi/ids — the same scheme +// validator NodeIDs use under strict-PQ. +// +// serviceIndex is BIP-32-hardened from servicePath: +// +// serviceIndex = SHAKE256(servicePath)[:4] mod 2^31 +// +// 31-bit space leaves the hardened-child top bit clear; collisions are +// astronomically unlikely for the few-hundred services any one cluster +// runs, but two distinct paths producing the same NodeID is a verifiable +// programmer error (NewServiceIdentity returns the same key — the caller +// chose colliding paths). +// +// Production: +// +// bootstrap = keys.NewServiceIdentity(bootstrapMnemonic, "luxd/staking-bootstrap") +// mnemonic = keys.LoadMnemonic(ctx, kmsAddr, "main", "/mnemonic", bootstrap) +// id, err = keys.NewServiceIdentity(mnemonic, "hanzo/kms-operator") +// if err != nil { … } +// defer id.Wipe() +// sig, err := id.Sign(envelope) +// +// Identity is exclusively a value type plus a pure-function constructor. +// It owns no I/O. The KMS/operator wires it to a `keys.LoadMnemonic` call; +// tests pass a known mnemonic and assert determinism. + +package keys + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + + mldsa "github.com/luxfi/crypto/mldsa" + secp "github.com/luxfi/crypto/secp256k1" + bip32 "github.com/luxfi/go-bip32" + bip39 "github.com/luxfi/go-bip39" + "github.com/luxfi/ids" + "golang.org/x/crypto/hkdf" + "golang.org/x/crypto/sha3" +) + +// HybridClassicalLeafIndex is the BIP-32 leaf index for the secp256k1 +// (classical) component of a hybrid validator identity. Pinned at 0 so +// it coincides with the existing ML-DSA-65-only service identity leaf +// — a hybrid identity is a superset, never a different tree position. +const HybridClassicalLeafIndex uint32 = 0 + +// HybridPQLeafIndex is the BIP-32 leaf index for the ML-DSA-65 (PQ) +// component of a hybrid validator identity. Pinned at 1 so the PQ key +// derives at a sibling sub-path m/44'/9000'/serviceIndex'/0'/1' — +// distinct from the classical leaf and from the legacy ML-DSA-only +// leaf at index 0. The legacy path is preserved exactly; the new PQ +// leaf is purely additive. +const HybridPQLeafIndex uint32 = 1 + +// HybridClassicalDomain is the SHAKE256 customisation string for the +// secp256k1 component's scalar derivation. Pinned at v1. +const HybridClassicalDomain = "lux-hybrid-classical-secp256k1-v1" + +// HybridPQDomain is the SHAKE256 customisation string for the ML-DSA-65 +// component's seed derivation. Pinned at v1. Distinct from +// serviceIdentityDomain so a hybrid identity's PQ key is NEVER the same +// byte-string as the legacy ML-DSA-only identity at the same path — +// the two derivations are cryptographically separated. +const HybridPQDomain = "lux-hybrid-pq-mldsa65-v1" + +// CoinTypeUTXO is the SLIP-0044 coin_type for the UTXO-layout DAG-like +// chains — X-Chain (AVM) + P-Chain. Service identities derive under +// m/44'/9000'/'/0'/0' so they share the coin-type tree +// with X/P staking keys but never collide with account-zero derivations +// (serviceIndex is hashed from servicePath, never 0 in practice for +// any well-formed path). +const CoinTypeUTXO = 9000 + +// BIP44Purpose is the BIP-44 purpose constant. Pinned to 44 even though +// service identities don't carry funds — staying inside the BIP-44 +// purpose tree keeps the same mnemonic interoperable with the Lux +// wallet's existing derivation tree. +const BIP44Purpose = 44 + +// serviceIdentityDomain is the SHAKE256 customisation string for the +// ML-DSA-65 seed derivation. Pinned at v1; bumping invalidates every +// prior service NodeID, which is the correct behaviour for a hardfork +// of the derivation encoding. +const serviceIdentityDomain = "lux-svc-mldsa-v1" + +// EnvelopeDomain is the customisation prefix mixed into every envelope +// signature so signatures from one envelope shape (a KMS opcode call) +// cannot be replayed against another (e.g. a future RPC over the same +// wire). Pinned at v1. +const EnvelopeDomain = "lux-svc-envelope-v1" + +// ServiceChainID is the well-known chain identifier under which all +// service NodeIDs are derived. Distinct from any L1 chain ID so a +// service NodeID never accidentally validates against a chain's +// validator-set commitment. Set once and never bumped — the empty +// "service" string is the canonical seed. +// +// Use the helper ServiceChainIDForCluster if a deployment ever needs +// per-cluster service NodeID separation. The default (empty seed) is +// what every Hanzo cluster uses today. +var ServiceChainID = mustHashChainID("lux-service-identity") + +// ErrInvalidServicePath is returned when the servicePath argument is +// empty after trim. Empty paths would collapse every service to the +// same NodeID, which would silently mask configuration drift. +var ErrInvalidServicePath = errors.New("keys: service path is required") + +// ServiceIdentity binds a mnemonic-derived ML-DSA-65 signing key to +// its canonical NodeID. The struct owns the private key bytes; call +// Wipe() when done. +// +// Safe for concurrent use after construction — every field is read-only +// after NewServiceIdentity returns. Wipe is the only mutating method +// and the caller serialises it (typically a single defer). +type ServiceIdentity struct { + // ServicePath is the canonical path string (verbatim) used to derive + // the BIP-32 hardened index. Stored for diagnostics; the + // authoritative input is the derived NodeID. + ServicePath string + + // NodeID is the 20-byte canonical NodeID derived under + // NodeIDSchemeMLDSA65. Map-key safe. + NodeID ids.NodeID + + // TypedNodeID is the wire-form NodeID (scheme byte || NodeID). + // Travels in envelope headers so the receiver knows which verifier + // to dispatch. + TypedNodeID ids.TypedNodeID + + // FullDigest is the 48-byte SHAKE256-384 commitment to the + // identity. Bound into envelope signatures to prevent cross-scheme + // confusion attacks. + FullDigest ids.FullDigest + + // PublicKey is the ML-DSA-65 public key bytes. + PublicKey []byte + + // privateKey is the ML-DSA-65 private key bytes. Never exposed via + // a getter; the only legal use is internal Sign(). + privateKey []byte +} + +// NewServiceIdentity is the canonical constructor. mnemonic must be a +// valid BIP-39 phrase; servicePath must be non-empty. Returns the +// derived ServiceIdentity ready to Sign(). +// +// Pure function: given the same (mnemonic, servicePath) you get the +// same NodeID, byte-for-byte. No I/O, no randomness, no clock reads. +func NewServiceIdentity(mnemonic, servicePath string) (*ServiceIdentity, error) { + if !bip39.IsMnemonicValid(mnemonic) { + return nil, errors.New("keys: invalid BIP-39 mnemonic") + } + servicePath = trimServicePath(servicePath) + if servicePath == "" { + return nil, ErrInvalidServicePath + } + + seed := bip39.NewSeed(mnemonic, "") + master, err := bip32.NewMasterKey(seed) + if err != nil { + return nil, fmt.Errorf("keys: bip32 master: %w", err) + } + + // m/44' / 9000' / serviceIndex' / 0' / 0' + purpose, err := master.NewChildKey(bip32.FirstHardenedChild + BIP44Purpose) + if err != nil { + return nil, fmt.Errorf("keys: derive purpose: %w", err) + } + coin, err := purpose.NewChildKey(bip32.FirstHardenedChild + CoinTypeUTXO) + if err != nil { + return nil, fmt.Errorf("keys: derive coin: %w", err) + } + account, err := coin.NewChildKey(bip32.FirstHardenedChild + serviceIndex(servicePath)) + if err != nil { + return nil, fmt.Errorf("keys: derive account: %w", err) + } + role, err := account.NewChildKey(bip32.FirstHardenedChild + 0) + if err != nil { + return nil, fmt.Errorf("keys: derive role: %w", err) + } + leaf, err := role.NewChildKey(bip32.FirstHardenedChild + 0) + if err != nil { + return nil, fmt.Errorf("keys: derive leaf: %w", err) + } + + // ML-DSA-65 keygen is deterministic in its randomness stream: the + // same HKDF-Expand output drives circl's keygen to the same + // keypair byte-for-byte. We seed HKDF with the hardened BIP-32 + // key plus a domain string + servicePath; if an attacker ever + // recovered the HKDF seed they still need the mnemonic to walk + // back the BIP-32 chain. + mldsaSeed := mldsaSeedFor(leaf.Key, servicePath) + reader := hkdf.New(sha3.New256, mldsaSeed, nil, []byte(serviceIdentityDomain)) + priv, err := mldsa.GenerateKey(reader, mldsa.MLDSA65) + if err != nil { + return nil, fmt.Errorf("keys: mldsa keygen: %w", err) + } + pubBytes := priv.PublicKey.Bytes() + privBytes := append([]byte(nil), priv.Bytes()...) + + scheme := ids.NodeIDSchemeMLDSA65 + typed, full, err := ids.TypedNodeIDFromMLDSA(scheme, ServiceChainID, pubBytes) + if err != nil { + return nil, fmt.Errorf("keys: derive node id: %w", err) + } + + return &ServiceIdentity{ + ServicePath: servicePath, + NodeID: typed.NodeID, + TypedNodeID: typed, + FullDigest: full, + PublicKey: pubBytes, + privateKey: privBytes, + }, nil +} + +// Sign produces a deterministic ML-DSA-65 signature over the envelope +// digest. The caller is responsible for serialising the envelope into +// canonical bytes before calling Sign — see SignEnvelope for the +// canonical (method, path, payload, timestamp, nonce) shape. +// +// The signed bytes are the SHAKE256 digest of: +// +// left_encode(|domain|·8) || EnvelopeDomain || +// left_encode(|full_digest|·8) || FullDigest || +// left_encode(|envelope|·8) || envelope +// +// Binding the FullDigest into the prehash means a verifier always +// rejects an envelope signed by a different identity — even a key with +// the same NodeID prefix. +func (s *ServiceIdentity) Sign(envelope []byte) ([]byte, error) { + if s == nil || len(s.privateKey) == 0 { + return nil, errors.New("keys: service identity is empty (wiped?)") + } + digest := envelopeDigest(s.FullDigest, envelope) + priv, err := mldsa.PrivateKeyFromBytes(mldsa.MLDSA65, s.privateKey) + if err != nil { + return nil, fmt.Errorf("keys: parse private key: %w", err) + } + // FIPS 204 §5.2 hedged sign — circl reads its own randomness + // internally. The EnvelopeDomain context byte string is bound + // into the signature so a cross-protocol replay of the same key + // against a different envelope shape rejects. + sig, err := priv.SignCtx(nil, digest, []byte(EnvelopeDomain)) + if err != nil { + return nil, fmt.Errorf("keys: sign: %w", err) + } + return sig, nil +} + +// VerifyServiceEnvelope verifies an ML-DSA-65 signature against an +// envelope produced by Sign. The caller supplies the signer's +// FullDigest (the 48-byte commitment carried in the envelope header) +// and public key bytes — both authenticated by the consensus layer. +// +// Pure function: no I/O, no time dependency. +func VerifyServiceEnvelope(pubKey []byte, fullDigest ids.FullDigest, envelope, sig []byte) error { + if len(pubKey) == 0 { + return errors.New("keys: empty public key") + } + if len(sig) == 0 { + return errors.New("keys: empty signature") + } + pub, err := mldsa.PublicKeyFromBytes(pubKey, mldsa.MLDSA65) + if err != nil { + return fmt.Errorf("keys: parse public key: %w", err) + } + digest := envelopeDigest(fullDigest, envelope) + if !pub.VerifySignatureCtx(digest, sig, []byte(EnvelopeDomain)) { + return errors.New("keys: envelope signature verification failed") + } + return nil +} + +// Wipe zeroes the private key in place. Idempotent. Safe to call from +// a defer. +func (s *ServiceIdentity) Wipe() { + if s == nil { + return + } + for i := range s.privateKey { + s.privateKey[i] = 0 + } + s.privateKey = nil +} + +// envelopeDigest computes the canonical SHAKE256 digest a signature +// binds. Exported so a hand-rolled verifier (the kmsd consensus_auth +// path) can call the same helper. +func envelopeDigest(fullDigest ids.FullDigest, envelope []byte) []byte { + h := sha3.NewShake256() + _, _ = h.Write(leftEncode(uint64(len(EnvelopeDomain)) * 8)) + _, _ = h.Write([]byte(EnvelopeDomain)) + _, _ = h.Write(leftEncode(uint64(ids.FullDigestLen) * 8)) + _, _ = h.Write(fullDigest[:]) + _, _ = h.Write(leftEncode(uint64(len(envelope)) * 8)) + _, _ = h.Write(envelope) + out := make([]byte, 32) + _, _ = h.Read(out) + return out +} + +// mldsaSeedFor returns the 32-byte deterministic seed for ML-DSA-65 +// key generation. SHAKE256 absorbs the BIP-32 hardened child key, the +// domain string, and the servicePath so the seed is unique per +// (mnemonic, path) tuple. +func mldsaSeedFor(hardenedKey []byte, servicePath string) []byte { + h := sha3.NewShake256() + _, _ = h.Write(leftEncode(uint64(len(serviceIdentityDomain)) * 8)) + _, _ = h.Write([]byte(serviceIdentityDomain)) + _, _ = h.Write(leftEncode(uint64(len(hardenedKey)) * 8)) + _, _ = h.Write(hardenedKey) + _, _ = h.Write(leftEncode(uint64(len(servicePath)) * 8)) + _, _ = h.Write([]byte(servicePath)) + out := make([]byte, 32) + _, _ = h.Read(out) + return out +} + +// serviceIndex hashes a servicePath into a hardened-safe BIP-32 child +// index. The top bit is masked off so adding bip32.FirstHardenedChild +// in the caller stays inside the hardened space without overflowing. +func serviceIndex(servicePath string) uint32 { + sum := sha256.Sum256([]byte(servicePath)) + v := binary.BigEndian.Uint32(sum[:4]) + return v & 0x7FFFFFFF +} + +// trimServicePath collapses whitespace and leading/trailing slashes +// without rewriting embedded slashes. Empty input returns "". +func trimServicePath(p string) string { + // strip ASCII whitespace + out := make([]byte, 0, len(p)) + for i := 0; i < len(p); i++ { + c := p[i] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + continue + } + out = append(out, c) + } + // trim leading/trailing '/' + for len(out) > 0 && out[0] == '/' { + out = out[1:] + } + for len(out) > 0 && out[len(out)-1] == '/' { + out = out[:len(out)-1] + } + return string(out) +} + +// mustHashChainID seeds the package-level ServiceChainID. Panics on +// the impossible (sha256 always succeeds); the panic surfaces a +// programmer error at init time rather than a runtime nil. +func mustHashChainID(seed string) ids.ID { + sum := sha256.Sum256([]byte(seed)) + var id ids.ID + copy(id[:], sum[:]) + return id +} + +// leftEncode is the SP 800-185 §2.3.1 left_encode operation. Kept +// local so service_identity.go has no cross-package dependency on +// luxfi/ids's private helpers — same byte-for-byte algorithm. +func leftEncode(x uint64) []byte { + if x == 0 { + return []byte{0x01, 0x00} + } + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], x) + i := 0 + for i < 7 && buf[i] == 0 { + i++ + } + out := make([]byte, 0, 9-i) + out = append(out, byte(8-i)) + out = append(out, buf[i:]...) + return out +} + +// ServiceChainIDForCluster is a future hook for per-cluster NodeID +// separation. Today every Hanzo cluster shares ServiceChainID; if a +// future deployment ever needs to isolate two clusters' NodeID spaces +// (e.g. lux-mainnet vs hanzo-prod), the operator can override +// ServiceChainID at boot via this helper. Pure function — no global +// state mutated on call. +func ServiceChainIDForCluster(clusterSeed string) ids.ID { + if clusterSeed == "" { + return ServiceChainID + } + return mustHashChainID("lux-service-identity:" + clusterSeed) +} + +// HybridIdentity binds a mnemonic-derived BBF-bound hybrid signing key +// (secp256k1 + ML-DSA-65) to its canonical NodeID. The struct owns the +// joint private key; call Wipe() when done. +// +// The NodeID is committed to the wire-form joint pubkey via the +// existing ids.NodeIDSchemeMLDSA65 derivation — chosen because the +// PQ component is the binding primitive (an adversary that holds only +// the classical key cannot mint a NodeID's matching FullDigest). Per +// cryptographer review the single-SHAKE-256-384 derivation is sound; +// no BTC-style double-hash is added. +type HybridIdentity struct { + // ServicePath is the canonical path string (verbatim) used to + // derive both hybrid components. Stored for diagnostics. + ServicePath string + + // NodeID is the 20-byte canonical NodeID derived under + // NodeIDSchemeMLDSA65 over the wire-form hybrid public key. + NodeID ids.NodeID + + // TypedNodeID is the wire-form NodeID (scheme byte || NodeID). + TypedNodeID ids.TypedNodeID + + // FullDigest is the 48-byte SHAKE256-384 commitment to the + // hybrid identity. Bound into envelope signatures. + FullDigest ids.FullDigest + + // PublicKey is the joint public key value. Both components are + // always populated. + PublicKey *HybridPublicKey + + // PublicKeyBytes is the canonical wire encoding of the joint + // pubkey — useful for logging, storage, and on-chain commitment. + // Same value HybridPublicKeyBytes(PublicKey) returns. + PublicKeyBytes []byte + + // privateKey is the joint signing key. Never exposed via a + // getter; the only legal use is internal Sign(). + privateKey *HybridPrivateKey +} + +// DeriveHybridIdentity is the canonical constructor for a BBF-bound +// hybrid validator identity. mnemonic must be a valid BIP-39 phrase; +// servicePath must be non-empty. Returns the derived HybridIdentity +// ready to Sign(). +// +// Derivation tree: +// +// classical: m/44' / 9000' / serviceIndex' / 0' / 0' (secp256k1) +// pq: m/44' / 9000' / serviceIndex' / 0' / 1' (ML-DSA-65) +// +// The two leaves share the same hardened branches up to the role +// node; the only branch that distinguishes them is the leaf index +// (0 vs 1). Both leaves are hardened. The classical leaf at index 0 +// COINCIDES with the legacy ML-DSA-only service identity path — this +// is intentional: a hybrid identity is a superset that adds a PQ +// component, never a different tree position. The classical leaf is +// then KDF-mixed with HybridClassicalDomain so the secp256k1 scalar +// is cryptographically separated from any ML-DSA-only seed at the +// same BIP-32 leaf — they share the leaf but not the seed. +// +// Pure function: given the same (mnemonic, servicePath) you get the +// same HybridIdentity, byte-for-byte. No I/O, no randomness, no clock +// reads. +func DeriveHybridIdentity(mnemonic, servicePath string) (*HybridIdentity, error) { + if !bip39.IsMnemonicValid(mnemonic) { + return nil, errors.New("keys: invalid BIP-39 mnemonic") + } + servicePath = trimServicePath(servicePath) + if servicePath == "" { + return nil, ErrInvalidServicePath + } + + seed := bip39.NewSeed(mnemonic, "") + master, err := bip32.NewMasterKey(seed) + if err != nil { + return nil, fmt.Errorf("keys: bip32 master: %w", err) + } + + // Walk to the shared role node: m/44'/9000'/serviceIndex'/0'. + purpose, err := master.NewChildKey(bip32.FirstHardenedChild + BIP44Purpose) + if err != nil { + return nil, fmt.Errorf("keys: derive purpose: %w", err) + } + coin, err := purpose.NewChildKey(bip32.FirstHardenedChild + CoinTypeUTXO) + if err != nil { + return nil, fmt.Errorf("keys: derive coin: %w", err) + } + account, err := coin.NewChildKey(bip32.FirstHardenedChild + serviceIndex(servicePath)) + if err != nil { + return nil, fmt.Errorf("keys: derive account: %w", err) + } + role, err := account.NewChildKey(bip32.FirstHardenedChild + 0) + if err != nil { + return nil, fmt.Errorf("keys: derive role: %w", err) + } + + // Classical leaf: m/44'/9000'/serviceIndex'/0'/0' (hardened). + classicalLeaf, err := role.NewChildKey(bip32.FirstHardenedChild + HybridClassicalLeafIndex) + if err != nil { + return nil, fmt.Errorf("keys: derive classical leaf: %w", err) + } + classicalKey, err := classicalScalarFor(classicalLeaf.Key, servicePath) + if err != nil { + return nil, fmt.Errorf("keys: classical keygen: %w", err) + } + + // PQ leaf: m/44'/9000'/serviceIndex'/0'/1' (hardened). + pqLeaf, err := role.NewChildKey(bip32.FirstHardenedChild + HybridPQLeafIndex) + if err != nil { + return nil, fmt.Errorf("keys: derive pq leaf: %w", err) + } + pqKey, err := pqKeyFor(pqLeaf.Key, servicePath) + if err != nil { + return nil, fmt.Errorf("keys: pq keygen: %w", err) + } + + sk := &HybridPrivateKey{ + Classical: classicalKey, + PQ: pqKey, + } + pk := sk.Public() + pkBytes, err := HybridPublicKeyBytes(pk) + if err != nil { + return nil, fmt.Errorf("keys: marshal hybrid pubkey: %w", err) + } + + // NodeID derivation: SHAKE256-384("NODE_ID_V1" || ServiceChainID + // || 0x42 || wire-form hybrid pubkey)[:20]. The 0x42 scheme byte + // matches the existing ML-DSA-65 scheme — the FullDigest is bound + // to the WHOLE hybrid pubkey (both components) so a hybrid NodeID + // is never the same as either component's NodeID-on-its-own. + scheme := ids.NodeIDSchemeMLDSA65 + typed, full, err := ids.TypedNodeIDFromMLDSA(scheme, ServiceChainID, pkBytes) + if err != nil { + return nil, fmt.Errorf("keys: derive node id: %w", err) + } + + return &HybridIdentity{ + ServicePath: servicePath, + NodeID: typed.NodeID, + TypedNodeID: typed, + FullDigest: full, + PublicKey: pk, + PublicKeyBytes: pkBytes, + privateKey: sk, + }, nil +} + +// Sign produces a BBF-bound joint signature over msg. Delegates to +// HybridSign — the identity owns the joint private key. +func (h *HybridIdentity) Sign(msg []byte) (*HybridSignature, error) { + if h == nil || h.privateKey == nil { + return nil, errors.New("keys: hybrid identity is empty (wiped?)") + } + // nil randSource → crypto/rand inside HybridSign. + return HybridSign(h.privateKey, msg, nil) +} + +// Wipe zeroes the joint private key in place. Idempotent. Safe to call +// from a defer on a nil receiver. +func (h *HybridIdentity) Wipe() { + if h == nil { + return + } + if h.privateKey != nil { + h.privateKey.Wipe() + h.privateKey = nil + } +} + +// classicalScalarFor returns a deterministic secp256k1 private key +// derived from a BIP-32 hardened leaf key + servicePath. SHAKE256 +// absorbs the leaf key, the HybridClassicalDomain string, and the +// servicePath; the resulting 32-byte digest is reduced mod the +// secp256k1 group order by secp.ToPrivateKey, which rejects 0 and +// scalars >= n. +// +// Pure function: same (leafKey, servicePath) → same private key. +func classicalScalarFor(leafKey []byte, servicePath string) (*secp.PrivateKey, error) { + h := sha3.NewShake256() + _, _ = h.Write(leftEncode(uint64(len(HybridClassicalDomain)) * 8)) + _, _ = h.Write([]byte(HybridClassicalDomain)) + _, _ = h.Write(leftEncode(uint64(len(leafKey)) * 8)) + _, _ = h.Write(leafKey) + _, _ = h.Write(leftEncode(uint64(len(servicePath)) * 8)) + _, _ = h.Write([]byte(servicePath)) + + // secp256k1 group order n ≈ 2^256 - 2^32 - 977. The probability + // that a uniform 32-byte sample is >= n is ~2^-128 — astronomical. + // secp.ToPrivateKey rejects >= n and == 0; the loop below + // re-samples by extending the SHAKE squeeze. In practice the loop + // body runs exactly once for every well-formed input. + for { + var scalar [32]byte + _, _ = h.Read(scalar[:]) + priv, err := secp.ToPrivateKey(scalar[:]) + if err == nil { + return priv, nil + } + // Loop: SHAKE continues squeezing fresh bytes — this is the + // canonical resample idiom for rejection-sampled scalars. + } +} + +// pqKeyFor returns a deterministic ML-DSA-65 private key derived from +// a BIP-32 hardened leaf key + servicePath. The PQ derivation mirrors +// the ML-DSA-only service identity: HKDF-Expand seeds circl's keygen +// to the same keypair byte-for-byte. The HybridPQDomain string is +// distinct from serviceIdentityDomain so a hybrid identity's PQ key +// is NEVER the same byte-string as a legacy ML-DSA-only identity at +// the same leaf. +// +// Pure function: same (leafKey, servicePath) → same private key. +func pqKeyFor(leafKey []byte, servicePath string) (*mldsa.PrivateKey, error) { + h := sha3.NewShake256() + _, _ = h.Write(leftEncode(uint64(len(HybridPQDomain)) * 8)) + _, _ = h.Write([]byte(HybridPQDomain)) + _, _ = h.Write(leftEncode(uint64(len(leafKey)) * 8)) + _, _ = h.Write(leafKey) + _, _ = h.Write(leftEncode(uint64(len(servicePath)) * 8)) + _, _ = h.Write([]byte(servicePath)) + seed := make([]byte, 32) + _, _ = h.Read(seed) + + reader := hkdf.New(sha3.New256, seed, nil, []byte(HybridPQDomain)) + return mldsa.GenerateKey(reader, mldsa.MLDSA65) +}