mirror of
https://github.com/luxfi/keys.git
synced 2026-07-26 23:58:11 +00:00
feat(identity): add cycle-free ServiceIdentity + Hybrid (mnemonic-derived ML-DSA-65 service auth)
service_identity.go + hybrid.go are pure-function, I/O-free derivation (BIP-39 -> BIP-32 -> ML-DSA-65 NodeID) that imports only luxfi/crypto + go-bip32/39 + ids — NO kms import, so no keys<->kms cycle. Previously these lived only vendored-in-kms; consumers (hanzoai/kms/sdk, bank, kms-operator) referenced keys.ServiceIdentity/NewServiceIdentity that the released keys lacked. This is the canonical home. (LoadMnemonic stays out of keys — it needs kms/zapclient.) Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'/<serviceIndex>'/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)
|
||||
}
|
||||
Reference in New Issue
Block a user