diff --git a/mixing/dcnet.go b/mixing/dcnet.go new file mode 100644 index 0000000000..7eaef83a8a --- /dev/null +++ b/mixing/dcnet.go @@ -0,0 +1,178 @@ +// Copyright (c) 2019-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "encoding/binary" + "math/big" + + "github.com/decred/dcrd/crypto/blake256" + "github.com/decred/dcrd/wire" +) + +// SRMixPads creates a vector of exponential DC-net pads from a vector of +// shared secrets with each participating peer in the DC-net. +func SRMixPads(kp [][]byte, my uint32) []*big.Int { + h := blake256.New() + scratch := make([]byte, 8) + + pads := make([]*big.Int, len(kp)) + partialPad := new(big.Int) + for j := uint32(0); j < uint32(len(kp)); j++ { + pads[j] = new(big.Int) + binary.LittleEndian.PutUint64(scratch, uint64(j)+1) + for i := uint32(0); i < uint32(len(kp)); i++ { + if my == i { + continue + } + h.Reset() + h.Write(kp[i]) + h.Write(scratch) + digest := h.Sum(nil) + partialPad.SetBytes(digest) + if my > i { + pads[j].Add(pads[j], partialPad) + } else { + pads[j].Sub(pads[j], partialPad) + } + } + pads[j].Mod(pads[j], F) + } + return pads +} + +// SRMix creates the padded {m**1, m**2, ..., m**n} message exponentials +// vector. Message must be bounded by the field prime and must be unique to +// every exponential SR run in a mix session to ensure anonymity. +func SRMix(m *big.Int, pads []*big.Int) []*big.Int { + mix := make([]*big.Int, len(pads)) + exp := new(big.Int) + for i := int64(0); i < int64(len(mix)); i++ { + mexp := new(big.Int).Exp(m, exp.SetInt64(i+1), nil) + mix[i] = mexp.Add(mexp, pads[i]) + mix[i].Mod(mix[i], F) + } + return mix +} + +// IntVectorsFromBytes creates a 2-dimensional *big.Int slice from their absolute +// values as bytes. +func IntVectorsFromBytes(vs [][][]byte) [][]*big.Int { + ints := make([][]*big.Int, len(vs)) + for i := range vs { + ints[i] = make([]*big.Int, len(vs[i])) + for j := range vs[i] { + ints[i][j] = new(big.Int).SetBytes(vs[i][j]) + } + } + return ints +} + +// IntVectorsToBytes creates a 2-dimensional slice of big.Int absolute values as +// bytes. +func IntVectorsToBytes(ints [][]*big.Int) [][][]byte { + bytes := make([][][]byte, len(ints)) + for i := range ints { + bytes[i] = make([][]byte, len(ints[i])) + for j := range ints[i] { + bytes[i][j] = ints[i][j].Bytes() + } + } + return bytes +} + +// AddVectors sums each vector element over F, returning a new vector. When +// peers are honest (DC-mix pads sum to zero) this creates the unpadded vector +// of message power sums. +func AddVectors(vs ...[]*big.Int) []*big.Int { + sums := make([]*big.Int, len(vs)) + for i := range sums { + sums[i] = new(big.Int) + for j := range vs { + sums[i].Add(sums[i], vs[j][i]) + } + sums[i].Mod(sums[i], F) + } + return sums +} + +// Coefficients calculates a{0}..a{n} for the polynomial: +// +// g(x) = a{0} + a{1}x + a{2}x**2 + ... + a{n-1}x**(n-1) + a{n}x**n (mod F) +// +// where +// +// a{n} = -1 +// a{n-1} = -(1/1) * a{n}*S{0} +// a{n-2} = -(1/2) * (a{n-1}*S{0} + a{n}*S{1}) +// a{n-3} = -(1/3) * (a{n-2}*S{0} + a{n-1}*S{1} + a{n}*S{2}) +// ... +// +// The roots of this polynomial are the set of recovered messages. +// +// Note that the returned slice of coefficients is one element larger than the +// slice of partial sums. +func Coefficients(S []*big.Int) []*big.Int { + n := len(S) + 1 + a := make([]*big.Int, n) + a[len(a)-1] = big.NewInt(-1) + a[len(a)-1].Add(a[len(a)-1], F) // a{n} = -1 (mod F) = F - 1 + scratch := new(big.Int) + for i := 0; i < len(a)-1; i++ { + a[n-2-i] = new(big.Int) + for j := 0; j <= i; j++ { + a[n-2-i].Add(a[n-2-i], scratch.Mul(a[n-1-i+j], S[j])) + } + xinv := scratch.ModInverse(scratch.SetInt64(int64(i)+1), F) + xinv.Neg(xinv) + a[n-2-i].Mul(a[n-2-i], xinv) + a[n-2-i].Mod(a[n-2-i], F) + } + return a +} + +// IsRoot checks that the message m is a root of the polynomial with +// coefficients a (mod F) without solving for every root. +func IsRoot(m *big.Int, a []*big.Int) bool { + sum := new(big.Int) + scratch := new(big.Int) + for i := range a { + scratch.Exp(m, scratch.SetInt64(int64(i)), F) + scratch.Mul(scratch, a[i]) + sum.Add(sum, scratch) + } + sum.Mod(sum, F) + return sum.Sign() == 0 +} + +// DCMixPads creates the vector of DC-net pads from shared secrets with each mix +// participant. +func DCMixPads(kp []wire.MixVect, my uint32) Vec { + pads := make(Vec, len(kp)) + for i := range kp { + if uint32(i) == my { + continue + } + pads.Xor(pads, Vec(kp[i])) + } + return pads +} + +// DCMix creates the DC-net vector of message m xor'd into m's reserved +// anonymous slot position of the pads DC-net pads. Panics if len(m) is not the +// vector's message size. +func DCMix(pads Vec, m []byte, slot uint32) Vec { + if len(m) != Msize { + panic("m is not len Msize") + } + + dcmix := make(Vec, len(pads)) + copy(dcmix, pads) + slotm := dcmix[slot][:] + for i := range m { + slotm[i] ^= m[i] + } + return dcmix +} diff --git a/mixing/errors.go b/mixing/errors.go new file mode 100644 index 0000000000..579f264445 --- /dev/null +++ b/mixing/errors.go @@ -0,0 +1,28 @@ +// Copyright (c) 2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "errors" + "fmt" +) + +var ( + errInvalidPROrder = errors.New("invalid pair request order") + + errInvalidSessionID = errors.New("invalid session ID") +) + +// DecapsulateError identifies the unmixed peer position of a peer who +// submitted an undecryptable ciphertext. +type DecapsulateError struct { + SubmittingIndex uint32 +} + +// Error satisifies the error interface. +func (e *DecapsulateError) Error() string { + return fmt.Sprintf("decapsulate failure of ciphertext by peer %d", + e.SubmittingIndex) +} diff --git a/mixing/expiry.go b/mixing/expiry.go new file mode 100644 index 0000000000..f2d6c7d293 --- /dev/null +++ b/mixing/expiry.go @@ -0,0 +1,18 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "time" + + "github.com/decred/dcrd/chaincfg/v3" +) + +// MaxExpiry returns the maximum allowed expiry for a new pair request message +// created with a blockchain tip at tipHeight. +func MaxExpiry(tipHeight uint32, params *chaincfg.Params) uint32 { + target := params.TargetTimePerBlock + return tipHeight + uint32(60*time.Minute/target) + 1 +} diff --git a/mixing/field.go b/mixing/field.go new file mode 100644 index 0000000000..6ff96b9f91 --- /dev/null +++ b/mixing/field.go @@ -0,0 +1,17 @@ +package mixing + +import ( + "math/big" +) + +// FieldPrime is the field prime 2**127 - 1. +var F *big.Int + +func init() { + F, _ = new(big.Int).SetString("7fffffffffffffffffffffffffffffff", 16) +} + +// InField returns whether x is bounded by the field F. +func InField(x *big.Int) bool { + return x.Sign() != -1 && x.Cmp(F) == -1 +} diff --git a/mixing/flags.go b/mixing/flags.go new file mode 100644 index 0000000000..07ae122eb0 --- /dev/null +++ b/mixing/flags.go @@ -0,0 +1,8 @@ +package mixing + +const ( + // PRFlagCanSolveRoots describes a bit in the pair request flags field + // indicating support for solving and publishing factored slot + // reservation polynomials. + PRFlagCanSolveRoots byte = 1 << iota +) diff --git a/mixing/go.mod b/mixing/go.mod new file mode 100644 index 0000000000..4281eac096 --- /dev/null +++ b/mixing/go.mod @@ -0,0 +1,21 @@ +module github.com/decred/dcrd/mixing + +go 1.17 + +require ( + github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a + github.com/decred/dcrd/chaincfg/chainhash v1.0.4 + github.com/decred/dcrd/chaincfg/v3 v3.2.0 + github.com/decred/dcrd/crypto/blake256 v1.0.1 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 + github.com/decred/dcrd/wire v1.6.0 + golang.org/x/crypto v0.7.0 +) + +require ( + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + golang.org/x/sys v0.6.0 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) + +replace github.com/decred/dcrd/wire => ../wire diff --git a/mixing/go.sum b/mixing/go.sum new file mode 100644 index 0000000000..1742c4f78a --- /dev/null +++ b/mixing/go.sum @@ -0,0 +1,55 @@ +github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a h1:clYxJ3Os0EQUKDDVU8M0oipllX0EkuFNBfhVQuIfyF0= +github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a/go.mod h1:z/9Ck1EDixEbBbZ2KH2qNHekEmDLTOZ+FyoIPWWSVOI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4 h1:zRCv6tdncLfLTKYqu7hrXvs7hW+8FO/NvwoFvGsrluU= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4/go.mod h1:hA86XxlBWwHivMvxzXTSD0ZCG/LoYsFdWnCekkTMCqY= +github.com/decred/dcrd/chaincfg/v3 v3.2.0 h1:6WxA92AGBkycEuWvxtZMvA76FbzbkDRoK8OGbsR2muk= +github.com/decred/dcrd/chaincfg/v3 v3.2.0/go.mod h1:2rHW1TKyFmwZTVBLoU/Cmf0oxcpBjUEegbSlBfrsriI= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/wire v1.6.0 h1:YOGwPHk4nzGr6OIwUGb8crJYWDiVLpuMxfDBCCF7s/o= +github.com/decred/dcrd/wire v1.6.0/go.mod h1:XQ8Xv/pN/3xaDcb7sH8FBLS9cdgVctT7HpBKKGsIACk= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/mixing/internal/chacha20prng/prng.go b/mixing/internal/chacha20prng/prng.go new file mode 100644 index 0000000000..cca9a23574 --- /dev/null +++ b/mixing/internal/chacha20prng/prng.go @@ -0,0 +1,53 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package chacha20prng + +import ( + "encoding/binary" + "strconv" + + "golang.org/x/crypto/chacha20" +) + +// SeedSize is the required length of seeds for New. +const SeedSize = 32 + +// Reader is a ChaCha20 PRNG for a DC-net run. It implements io.Reader. +type Reader struct { + cipher *chacha20.Cipher +} + +// New creates a ChaCha20 PRNG seeded by a 32-byte key and a run iteration. The +// returned reader is not safe for concurrent access. This will panic if the +// length of seed is not SeedSize bytes. +func New(seed []byte, run uint32) *Reader { + if l := len(seed); l != SeedSize { + panic("chacha20prng: bad seed length " + strconv.Itoa(l)) + } + + nonce := make([]byte, chacha20.NonceSize) + binary.LittleEndian.PutUint32(nonce[:4], run) + + cipher, _ := chacha20.NewUnauthenticatedCipher(seed, nonce) + return &Reader{cipher: cipher} +} + +// Read implements io.Reader. +func (r *Reader) Read(b []byte) (int, error) { + // Zero the source such that the destination is written with just the + // keystream. Destination and source are allowed to overlap (exactly). + for i := range b { + b[i] = 0 + } + r.cipher.XORKeyStream(b, b) + return len(b), nil +} + +// Next returns the next n bytes from the reader. +func (r *Reader) Next(n int) []byte { + b := make([]byte, n) + r.cipher.XORKeyStream(b, b) + return b +} diff --git a/mixing/keyagreement.go b/mixing/keyagreement.go new file mode 100644 index 0000000000..f36c3b5c5b --- /dev/null +++ b/mixing/keyagreement.go @@ -0,0 +1,237 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + cryptorand "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/companyzero/sntrup4591761" + "github.com/decred/dcrd/crypto/blake256" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/mixing/internal/chacha20prng" + "github.com/decred/dcrd/wire" +) + +// Aliases for sntrup4591761 types +type ( + PQPublicKey = [sntrup4591761.PublicKeySize]byte + PQPrivateKey = [sntrup4591761.PrivateKeySize]byte + PQCiphertext = [sntrup4591761.CiphertextSize]byte + PQSharedKey = [sntrup4591761.SharedKeySize]byte +) + +func generateSecp256k1(rand io.Reader) (*secp256k1.PublicKey, *secp256k1.PrivateKey, error) { + if rand == nil { + rand = cryptorand.Reader + } + + privateKey, err := secp256k1.GeneratePrivateKeyFromRand(rand) + if err != nil { + return nil, nil, err + } + + publicKey := privateKey.PubKey() + + return publicKey, privateKey, nil +} + +// KX contains the client public and private keys to perform shared key exchange +// with other peers. +type KX struct { + ECDHPublicKey *secp256k1.PublicKey + ECDHPrivateKey *secp256k1.PrivateKey + PQPublicKey *PQPublicKey + PQPrivateKey *PQPrivateKey + PQCleartexts []PQSharedKey +} + +// NewKX generates a mixing identity's public and private keys for a interactive +// key exchange, with randomness read from a run's CSPRNG. +func NewKX(csprng io.Reader) (*KX, error) { + ecdhPublic, ecdhPrivate, err := generateSecp256k1(csprng) + if err != nil { + return nil, err + } + + pqPublic, pqPrivate, err := sntrup4591761.GenerateKey(csprng) + if err != nil { + return nil, err + } + + kx := &KX{ + ECDHPublicKey: ecdhPublic, + ECDHPrivateKey: ecdhPrivate, + PQPublicKey: pqPublic, + PQPrivateKey: pqPrivate, + } + return kx, nil +} + +func (kx *KX) ecdhSharedKey(pub *secp256k1.PublicKey) []byte { + secret := secp256k1.GenerateSharedSecret(kx.ECDHPrivateKey, pub) + hash := blake256.Sum256(secret) + return hash[:] +} + +func (kx *KX) pqSharedKey(ciphertext *PQCiphertext) ([]byte, error) { + pqSharedKey, ok := sntrup4591761.Decapsulate(ciphertext, kx.PQPrivateKey) + if ok != 1 { + return nil, errors.New("sntrup4591761: decapsulate failure") + } + return pqSharedKey[:], nil +} + +// Encapsulate performs encapsulation for sntrup4591761 key exchanges with each +// other peer in the DC-net. It populates the PQCleartexts field of kx and +// returns encrypted cyphertexts of these shared keys. +// +// Encapsulation in the DC-net requires randomness from a CSPRNG seeded by a +// committed secret; blame assignment is not possible otherwise. +func (kx *KX) Encapsulate(prng io.Reader, pubkeys []*PQPublicKey, my int) ([]PQCiphertext, error) { + cts := make([][sntrup4591761.CiphertextSize]byte, len(pubkeys)) + kx.PQCleartexts = make([][32]byte, len(pubkeys)) + + for i, pk := range pubkeys { + ciphertext, cleartext, err := sntrup4591761.Encapsulate(prng, pk) + if err != nil { + return nil, err + } + cts[i] = *ciphertext + kx.PQCleartexts[i] = *cleartext + } + + return cts, nil +} + +// RevealedKeys records the revealed ECDH public keys of every peer and the +// ciphertexts created for a single peer at MyIndex. +type RevealedKeys struct { + ECDHPublicKeys []*secp256k1.PublicKey + Ciphertexts []PQCiphertext + MyIndex uint32 +} + +// SharedSecrets is a return value for the KX.SharedSecrets method, housing +// the slot reservation and XOR DC-Net shared secrets between two peers. +type SharedSecrets struct { + SRSecrets [][][]byte + DCSecrets [][]wire.MixVect +} + +// SharedKeys creates the pairwise SR and DC shared secret keys for +// mcounts[k.MyIndex] mixes. ecdhPubs, cts, and mcounts must all share the same +// slice length. +func (kx *KX) SharedSecrets(k *RevealedKeys, sid []byte, run uint32, mcounts []uint32) (SharedSecrets, error) { + var s SharedSecrets + + if len(k.ECDHPublicKeys) != len(mcounts) { + err := fmt.Errorf("ECDH public key count (%d) must match peer count (%d)", + len(k.ECDHPublicKeys), len(mcounts)) + return s, err + } + if len(k.Ciphertexts) != len(mcounts) { + err := fmt.Errorf("ciphertext count (%d) must match peer count (%d)", + len(k.Ciphertexts), len(mcounts)) + return s, err + } + + mcount := mcounts[k.MyIndex] + var mtot uint32 + for i := range mcounts { + mtot += mcounts[i] + } + + s.SRSecrets = make([][][]byte, mcount) + s.DCSecrets = make([][]wire.MixVect, mcount) + + for i := uint32(0); i < mcount; i++ { + s.SRSecrets[i] = make([][]byte, mtot) + s.DCSecrets[i] = make([]wire.MixVect, mtot) + var m int + for peer := uint32(0); int(peer) < len(mcounts); peer++ { + if peer == k.MyIndex && mcount == 1 { + m++ + continue + } + + sharedKey := kx.ecdhSharedKey(k.ECDHPublicKeys[peer]) + pqSharedKey, err := kx.pqSharedKey(&k.Ciphertexts[peer]) + if err != nil { + err := &DecapsulateError{ + SubmittingIndex: peer, + } + return s, err + } + + // XOR ECDH and both sntrup4591761 keys into a single + // shared key. If sntrup4591761 is discovered to be + // broken in the future, the security only reduces to + // that of x25519. + // If the message belongs to our own peer, only XOR + // the sntrup4591761 key once. The decapsulated and + // cleartext keys are equal in this case, and would + // cancel each other out otherwise. + xor := func(dst, src []byte) { + if len(dst) != len(src) { + panic("dcnet: different lengths in xor") + } + for i := range dst { + dst[i] ^= src[i] + } + } + xor(sharedKey, pqSharedKey[:]) + if peer != k.MyIndex { + xor(sharedKey, kx.PQCleartexts[peer][:]) + } + + // Create the prefix of a PRNG seed preimage. A counter + // will be appended before creating each PRNG, one for + // each message pair. + prngSeedPreimage := make([]byte, len(sid)+len(sharedKey)+4) + l := copy(prngSeedPreimage, sid) + l += copy(prngSeedPreimage[l:], sharedKey) + seedCounterBytes := prngSeedPreimage[l:] + + // Read from the PRNG to create shared keys for each + // message the peer is mixing. + for j := uint32(0); j < mcounts[peer]; j++ { + if k.MyIndex == peer && j == i { + m++ + continue + } + + // Create the PRNG seed using the combined shared key. + // A unique seed is generated for each message pair, + // determined using the message index of the peer with + // the lower peer index. The PRNG nonce is the message + // number of the peer with the higher peer index. + // When creating shared keys with our own peer, the PRNG + // seed counter and nonce must be reversed for the second + // half of our generated keys. + seedCounter := i + nonce := j + if k.MyIndex > peer || (k.MyIndex == peer && j > i) { + seedCounter = j + nonce = i + } + binary.LittleEndian.PutUint32(seedCounterBytes, seedCounter) + + prngSeed := blake256.Sum256(prngSeedPreimage) + prng := chacha20prng.New(prngSeed[:], nonce) + + s.SRSecrets[i][m] = prng.Next(32) + s.DCSecrets[i][m] = wire.MixVect(randVec(mtot, prng)) + + m++ + } + } + } + + return s, nil +} diff --git a/mixing/message.go b/mixing/message.go new file mode 100644 index 0000000000..5dde451f62 --- /dev/null +++ b/mixing/message.go @@ -0,0 +1,34 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "hash" + + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/wire" +) + +// Message is a mixing message. In addition to implementing wire encoding, +// these messages are signed by an ephemeral mixing participant identity, +// declare the previous messages that have been observed by a peer in a mixing +// session, and include expiry information to increase resilience to replay +// and denial-of-service attacks. +// +// All mixing messages satisify this interface, however, the pair request +// message returns nil for some fields that do not apply, as it is the first +// message in the protocol. +type Message interface { + wire.Message + + Pub() []byte + Sig() []byte + WriteHash(hash.Hash) + Hash() chainhash.Hash + WriteSignedData(hash.Hash) + PrevMsgs() []chainhash.Hash // PR, FP returns nil + Sid() []byte // PR returns nil + GetRun() uint32 // PR returns 0 +} diff --git a/mixing/scriptclass.go b/mixing/scriptclass.go new file mode 100644 index 0000000000..7264be561f --- /dev/null +++ b/mixing/scriptclass.go @@ -0,0 +1,16 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +// ScriptClass describes the type and format of scripts that can be used for +// mixed outputs. A mix may only be performed among all participants who agree +// on the same script class. +type ScriptClass string + +// Script class descriptors for the mixed outputs. +// Only secp256k1 P2PKH is allowed at this time. +const ( + ScriptClassP2PKHv0 ScriptClass = "P2PKH-secp256k1-v0" +) diff --git a/mixing/sid.go b/mixing/sid.go new file mode 100644 index 0000000000..7394bf60cd --- /dev/null +++ b/mixing/sid.go @@ -0,0 +1,122 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "bytes" + "encoding/binary" + "sort" + + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/crypto/blake256" + "github.com/decred/dcrd/wire" +) + +// deriveSessionID creates the mix session identifier from an initial sorted +// slice of PR message hashes. +func deriveSessionID(seenPRs []chainhash.Hash, epoch uint64) [32]byte { + h := blake256.New() + buf := make([]byte, 8) + + h.Write([]byte("decred-mix-session")) + + binary.BigEndian.PutUint64(buf, epoch) + h.Write(buf) + + for i := range seenPRs { + h.Write(seenPRs[i][:]) + } + + return *(*[32]byte)(h.Sum(nil)) +} + +// SortPRsForSession performs an in-place sort of prs, moving each pair +// request to its original unmixed position in the protocol. Returns the +// session ID. +func SortPRsForSession(prs []*wire.MsgMixPairReq, epoch uint64) [32]byte { + // Lexicographical sort PRs to derive the sid. + sort.Slice(prs, func(i, j int) bool { + a := prs[i].Hash() + b := prs[j].Hash() + return bytes.Compare(a[:], b[:]) == -1 + }) + + h := make([]chainhash.Hash, len(prs)) + for i, pr := range prs { + h[i] = pr.Hash() + } + sid := deriveSessionID(h, epoch) + + // XOR the sid into each PR hash. + for i := range h { + xor(h[i][:], sid[:]) + } + + // Lexicographical sort PRs by their hash XOR'd with the sid. + sort.Sort(&sortPRs{prs: prs, prXorSIDs: h}) + + return sid +} + +func xor(a, b []byte) { + for i := range a { + a[i] ^= b[i] + } +} + +type sortPRs struct { + prs []*wire.MsgMixPairReq + prXorSIDs []chainhash.Hash +} + +func (s *sortPRs) Len() int { + return len(s.prs) +} + +func (s *sortPRs) Less(i, j int) bool { + a := s.prXorSIDs[i][:] + b := s.prXorSIDs[j][:] + return bytes.Compare(a, b) == -1 +} + +func (s *sortPRs) Swap(i, j int) { + s.prs[i], s.prs[j] = s.prs[j], s.prs[i] + s.prXorSIDs[i], s.prXorSIDs[j] = s.prXorSIDs[j], s.prXorSIDs[i] +} + +// ValidateSession checks whether the original unmixed peer order of a key +// exchange's pair request hashes is validly sorted for the session ID, and +// for a run-0 KE, also checks that the session hash is derived from the +// specified pair requests and epoch. +func ValidateSession(ke *wire.MsgMixKeyExchange) error { + h := make([]chainhash.Hash, len(ke.SeenPRs)) + copy(h, ke.SeenPRs) + + // XOR the sid into each hash. The result should be sorted in all + // runs. + for i := range h { + xor(h[i][:], ke.SessionID[:]) + } + sorted := sort.SliceIsSorted(h, func(i, j int) bool { + return bytes.Compare(h[i][:], h[j][:]) == -1 + }) + if !sorted { + return errInvalidPROrder + } + + // If this is a run-0 KE, validate the session hash. + if ke.Run == 0 { + copy(h, ke.SeenPRs) + sort.Slice(h, func(i, j int) bool { + return bytes.Compare(h[i][:], h[j][:]) == -1 + }) + derivedSID := deriveSessionID(h, ke.Epoch) + if derivedSID != ke.SessionID { + return errInvalidSessionID + } + } + + return nil +} diff --git a/mixing/sid_test.go b/mixing/sid_test.go new file mode 100644 index 0000000000..8225cb0683 --- /dev/null +++ b/mixing/sid_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "bytes" + "encoding/hex" + "errors" + "sort" + "testing" + + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/wire" +) + +func TestSessionID(t *testing.T) { + // PR hashes must be lexicographically sorted before call to + // deriveSessionID. + prs := []chainhash.Hash{{0}, {1}, {2}, {3}, {4}, {5}} + sid := deriveSessionID(prs, 0) + + t.Logf("sid: %x", sid[:]) + for i := range prs { + t.Logf("%d: %s", i, prs[i]) + } + + xorPRs := append(prs[:0:0], prs...) + for i := range xorPRs { + xor(xorPRs[i][:], sid[:]) + } + for i := range xorPRs { + t.Logf("xorPRs[%d] before sort: %s", i, xorPRs[i]) + } + + sort.Slice(xorPRs, func(i, j int) bool { + return bytes.Compare(xorPRs[i][:], xorPRs[j][:]) == -1 + }) + for i := range xorPRs { + t.Logf("xorPRs[%d] after sort: %s", i, xorPRs[i]) + } + + prPosition := make(map[chainhash.Hash]int) + for i := range xorPRs { + originalHash := xorPRs[i] + xor(originalHash[:], sid[:]) + prPosition[originalHash] = i + } + + orderedPRs := make([]chainhash.Hash, len(prs)) + for prHash, pos := range prPosition { + orderedPRs[pos] = prHash + } + for i := range orderedPRs { + t.Logf("orderedPRs[%d]: %s", i, orderedPRs[i]) + } + + ke := &wire.MsgMixKeyExchange{ + SessionID: sid, + Epoch: 0, + Run: 0, + SeenPRs: orderedPRs, + } + if err := ValidateSession(ke); err != nil { + t.Errorf("ValidateSession: %v", err) + } +} + +func TestInvalidSession(t *testing.T) { + // Session and PR order from TestSessionID. + prs := []chainhash.Hash{{1}, {0}, {3}, {2}, {5}, {4}} + sidBytes, _ := hex.DecodeString("695794d3492979b67b51cd79fa330eaae643955ecea866b3e35d7abed2ec621e") + sid := *(*[32]byte)(sidBytes) + + ke := &wire.MsgMixKeyExchange{ + SessionID: sid, + Epoch: 0, + Run: 0, + SeenPRs: prs, + } + if err := ValidateSession(ke); err != nil { + t.Fatalf("ValidateSession: %v", err) + } + + ke.SessionID[16] ^= 0xFF + if err := ValidateSession(ke); !errors.Is(err, errInvalidSessionID) { + t.Errorf("ValidateSession unexpected error, got %v, want %v", + err, errInvalidSessionID) + } + + ke.SessionID = sid + ke.SeenPRs[0], ke.SeenPRs[1] = ke.SeenPRs[1], ke.SeenPRs[0] + if err := ValidateSession(ke); !errors.Is(err, errInvalidPROrder) { + t.Errorf("ValidateSession unexpected error, got %v, want %v", + err, errInvalidPROrder) + } +} diff --git a/mixing/signatures.go b/mixing/signatures.go new file mode 100644 index 0000000000..3df4fd49de --- /dev/null +++ b/mixing/signatures.go @@ -0,0 +1,114 @@ +// Copyright (c) 2023-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "bytes" + "fmt" + "hash" + + "github.com/decred/dcrd/crypto/blake256" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr" +) + +const tag = "decred-mix-signature" + +// Signed is an interface describing a signed mixing message. +type Signed interface { + Pub() []byte + Sig() []byte + Sid() []byte + GetRun() uint32 + Command() string + WriteSignedData(hash.Hash) +} + +// SignMessage creates a signature for the message m and writes the signature +// into the message. +func SignMessage(m Signed, priv *secp256k1.PrivateKey) error { + sig, err := sign(priv, m) + if err != nil { + return err + } + // XXX: A SetSig method or similar would be less janky. + copy(m.Sig(), sig) + return nil +} + +// VerifySignedMessage verifies that a signed message carries a valid +// signature for the represented identity. +func VerifySignedMessage(m Signed) bool { + h := blake256.New() + m.WriteSignedData(h) + sigHash := h.Sum(nil) + + h.Reset() + + command := m.Command() + sid := m.Sid() + run := m.GetRun() + if len(sid) != 32 { + sid = zeroSID[:] + run = 0 + } + + return verify(h, m.Pub(), m.Sig(), sigHash, command, sid, run) +} + +// VerifySignature verifies a message signature from its signature hash and +// information describing the message type and its place in the protocol. +// Multiple messages of the same command, sid, and run should not be signed by +// the same public key, and demonstrating this can be used to prove malicious +// behavior by sending different versions of messages through the network. +func VerifySignature(pub, sig, sigHash []byte, command string, sid []byte, run uint32) bool { + h := blake256.New() + return verify(h, pub, sig, sigHash, command, sid, run) +} + +var zeroSID [32]byte + +func sign(priv *secp256k1.PrivateKey, m Signed) ([]byte, error) { + h := blake256.New() + m.WriteSignedData(h) + sigHash := h.Sum(nil) + + h.Reset() + + sid := m.Sid() + run := m.GetRun() + if len(sid) != 32 { + sid = zeroSID[:] + run = 0 + } + + buf := new(bytes.Buffer) + fmt.Fprintf(buf, tag+",%s,%x,%d,%x", m.Command(), sid, run, sigHash) + h.Write(buf.Bytes()) + + sig, err := schnorr.Sign(priv, h.Sum(nil)) + if err != nil { + return nil, err + } + return sig.Serialize(), nil +} + +func verify(h hash.Hash, pk []byte, sig []byte, sigHash []byte, command string, sid []byte, run uint32) bool { + pkParsed, err := secp256k1.ParsePubKey(pk) + if err != nil { + return false + } + sigParsed, err := schnorr.ParseSignature(sig) + if err != nil { + return false + } + + h.Reset() + + buf := new(bytes.Buffer) + fmt.Fprintf(buf, tag+",%s,%x,%d,%x", command, sid, run, sigHash) + h.Write(buf.Bytes()) + return sigParsed.Verify(h.Sum(nil), pkParsed) +} diff --git a/mixing/vec.go b/mixing/vec.go new file mode 100644 index 0000000000..2293057a08 --- /dev/null +++ b/mixing/vec.go @@ -0,0 +1,78 @@ +// Copyright (c) 2019-2024 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixing + +import ( + "fmt" + "strings" + + "github.com/decred/dcrd/mixing/internal/chacha20prng" +) + +// Msize is the size of the message being mixed. This is the size of a +// HASH160, which allows mixes to be create either all P2PKH or P2SH outputs. +const Msize = 20 + +// Vec is a N-element vector of Msize []byte messages. +type Vec [][Msize]byte + +func randVec(n uint32, prng *chacha20prng.Reader) Vec { + v := make(Vec, n) + for i := range v { + prng.Read(v[i][:]) + } + return v +} + +// Equals returns whether the two vectors have equal dimensions and data. +func (v Vec) Equals(other Vec) bool { + if len(v) != len(other) { + return false + } + for i := range other { + if v[i] != other[i] { + return false + } + } + return true +} + +func (v Vec) String() string { + b := new(strings.Builder) + b.Grow(2 + len(v)*(2*Msize+1)) + b.WriteString("[") + for i := range v { + if i != 0 { + b.WriteString(" ") + } + fmt.Fprintf(b, "%x", v[i][:]) + } + b.WriteString("]") + return b.String() +} + +// Xor writes the xor of each vector element of src1 and src2 into v. +// Source and destination vectors are allowed to be equal. +// Panics if vectors do not share identical dimensions. +func (v Vec) Xor(src1, src2 Vec) { + if len(v) != len(src1) || len(v) != len(src2) { + panic("dcnet: vectors do not share identical dimensions") + } + for i := range v { + for j := range v[i] { + v[i][j] = src1[i][j] ^ src2[i][j] + } + } +} + +// XorVectors calculates the xor of all vectors. +// Panics if vectors do not share identical dimensions. +func XorVectors(vs []Vec) Vec { + res := make(Vec, len(vs[0])) + for i := range vs { + res.Xor(res, vs[i]) + } + return res +}