diff --git a/mixing/go.mod b/mixing/go.mod new file mode 100644 index 0000000000..23cf0c9756 --- /dev/null +++ b/mixing/go.mod @@ -0,0 +1,3 @@ +module github.com/decred/dcrd/mixing + +go 1.17 diff --git a/mixing/mixpool/log.go b/mixing/mixpool/log.go new file mode 100644 index 0000000000..596dcf9072 --- /dev/null +++ b/mixing/mixpool/log.go @@ -0,0 +1,31 @@ +// Copyright (c) 2020-2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package mixpool + +import ( + "github.com/decred/slog" +) + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +// The default amount of logging is none. +var log = slog.Disabled + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using slog. +func UseLogger(logger slog.Logger) { + log = logger +} + +// pickNoun returns the singular or plural form of a noun depending on the +// provided count. +func pickNoun(n uint64, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} diff --git a/mixing/mixpool/mixpool.go b/mixing/mixpool/mixpool.go new file mode 100644 index 0000000000..85be987f43 --- /dev/null +++ b/mixing/mixpool/mixpool.go @@ -0,0 +1,863 @@ +// 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 mixpool provides an in-memory pool of mixing messages for full nodes +// that relay these messages and mixing wallets that send and receive them. +package mixpool + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/binary" + "fmt" + "io" + "sort" + "sync" + "time" + + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/dcrd/crypto/blake256" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/decred/dcrd/dcrutil/v4" + "github.com/decred/dcrd/internal/blockchain" + "github.com/decred/dcrd/mixing" + "github.com/decred/dcrd/txscript/v4/stdscript" + "github.com/decred/dcrd/wire" +) + +const minconf = 2 +const feeRate = 0.0001e8 + +type idPubKey = [ed25519.PublicKeySize]byte + +// Message type constants, for quickly checking looked up entries by message +// hash match the expected type (without performing a type assertion). +// Excludes PR. +const ( + msgtypeKE = 1 + iota + msgtypeCT + msgtypeSR + msgtypeDC + msgtypeCM + msgtypeRS + + nmsgtypes = msgtypeRS +) + +// entry describes non-PR messages accepted to the pool. +type entry struct { + hash chainhash.Hash + sid [32]byte + msg Message + msgtype int + run uint32 +} + +type session struct { + sid [32]byte + runs []runstate + expiry int64 + bc broadcast +} + +type runstate struct { + run uint32 + npeers uint32 + counts [nmsgtypes]uint32 + hashes map[chainhash.Hash]struct{} +} + +type broadcast struct { + funcs []func() + ch chan struct{} + mu sync.Mutex +} + +// wait returns the wait channel that is closed when a broadcast is made due to +// receiving the expected number of messages for a session. +// Waiters must acquire the pool lock before reading messages. +func (b *broadcast) wait() <-chan struct{} { + b.mu.Lock() + ch := b.ch + b.mu.Unlock() + + return ch +} + +func (b *broadcast) signal() { + b.mu.Lock() + close(b.ch) + b.ch = make(chan struct{}) + b.mu.Unlock() +} + +// Pool records in-memory mix messages that have been broadcast over the +// peer-to-peer network. +type Pool struct { + mtx sync.RWMutex + prs map[chainhash.Hash]*wire.MsgMixPR + pool map[chainhash.Hash]entry + messagesByIdentity map[idPubKey][]chainhash.Hash + sessions map[[32]byte]*session + + blockchain BlockChain + utxoFetcher UtxoFetcher + feeRate int64 + params *chaincfg.Params +} + +// Message is a mixing message. In addition to the 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 + + Hash() chainhash.Hash + GetIdentity() []byte // XXX Get is ugly but avoids msg field name conflicts + GetSignature() []byte + WriteSigned(io.Writer) error + Expires() int64 + PrevMsgs() []chainhash.Hash // PR returns nil + Sid() []byte // PR returns nil + GetRun() uint32 // PR returns 0 +} + +// UtxoFetcher defines methods used to validate unspent transaction outputs in +// the pair request message. It is optional, but should be implemented by full +// nodes that have this capability to detect and stop relay of spam and junk +// messages. +type UtxoFetcher interface { + // FetchUtxoEntry defines the function to use to fetch unspent + // transaction output information. + // If this function is nil, verification of UTXOs is not performed. + // XXX may be better to use FetchUtxoView here? + FetchUtxoEntry(wire.OutPoint) (*blockchain.UtxoEntry, error) +} + +// BlockChain queries the current status of the blockchain. Its methods should +// be able to be implemented by both full nodes and SPV wallets. +type BlockChain interface { + // ChainParams identifies which chain parameters the mixing pool is + // associated with. + ChainParams() *chaincfg.Params + + // BestHeader returns the hash and height of the current tip block. + BestHeader() (chainhash.Hash, int64) +} + +// NewPool returns a new mixing pool that accepts and validates mixing messages +// required for distributed transaction mixing. +func NewPool(blockchain BlockChain) *Pool { + pool := &Pool{ + pool: make(map[chainhash.Hash]entry), + messagesByIdentity: make(map[idPubKey][]chainhash.Hash), + sessions: make(map[[32]byte]*session), + blockchain: blockchain, + feeRate: feeRate, + params: blockchain.ChainParams(), + } + if u, ok := blockchain.(UtxoFetcher); ok { + pool.utxoFetcher = u + } + return pool +} + +// MixPRHashes returns the hashes of all MixPR messages recorded by the pool. +// This data is provided to peers requesting inital state of the mixpool. +func (p *Pool) MixPRHashes() []chainhash.Hash { + p.mtx.RLock() + hashes := make([]chainhash.Hash, 0, len(p.prs)) + for hash := range p.prs { + hashes = append(hashes, hash) + } + p.mtx.RUnlock() + + return hashes +} + +// Message searches the mixing pool for a message by its hash. +func (p *Pool) Message(query *chainhash.Hash) (Message, error) { + p.mtx.RLock() + e, ok := p.pool[*query] + p.mtx.RUnlock() + if !ok || e.msg == nil { + return nil, fmt.Errorf("message not found") + } + return e.msg, nil +} + +// HaveMessage checks whether the mixing pool contains a message by its hash. +func (p *Pool) HaveMessage(query *chainhash.Hash) bool { + p.mtx.RLock() + e, ok := p.pool[*query] + p.mtx.RUnlock() + if !ok || e.msg == nil { + return false + } + return true +} + +// MixPR searches the mixing pool for a PR message by its hash. +func (p *Pool) MixPR(query *chainhash.Hash) (*wire.MsgMixPR, error) { + var pr *wire.MsgMixPR + + p.mtx.RLock() + e, ok := p.pool[*query] + p.mtx.RUnlock() + if ok { + pr, _ = e.msg.(*wire.MsgMixPR) + } + + if pr == nil { + return nil, fmt.Errorf("PR message not found") + } + + return pr, nil +} + +// MixPRs returns all MixPR messages with hashes matching the query. Unknown +// messages are ignored. +// +// If query is nil, all PRs are returned. +func (p *Pool) MixPRs(query []chainhash.Hash) []*wire.MsgMixPR { + res := make([]*wire.MsgMixPR, 0, len(query)) + + p.mtx.RLock() + defer p.mtx.RUnlock() + + if query == nil { + res = make([]*wire.MsgMixPR, 0, len(p.prs)) + for _, pr := range p.prs { + res = append(res, pr) + } + return res + } + + for i := range query { + e, ok := p.pool[query[i]] + if !ok { + continue + } + + pr, ok := e.msg.(*wire.MsgMixPR) + if ok { + res = append(res, pr) + } + } + + return res +} + +// CompatiblePRs returns all MixPR messages with pairing descriptions matching +// the parameter. +func (p *Pool) CompatiblePRs(pairing []byte) []*wire.MsgMixPR { + p.mtx.RLock() + defer p.mtx.RUnlock() + + res := make([]*wire.MsgMixPR, 0, len(p.prs)) + for _, pr := range p.prs { + prPairing, _ := pr.Pairing() + if bytes.Equal(pairing, prPairing) { + res = append(res, pr) + } + } + return res +} + +// ExpireMessages removes all messages and sessions that indicate an expiry +// height at or before the height parameter. +func (p *Pool) ExpireMessages(height int64) { + p.mtx.Lock() + defer p.mtx.Unlock() + + // Expire sessions and their messages + for sid, ses := range p.sessions { + if ses.expiry > height { + continue + } + + delete(p.sessions, sid) + for _, r := range ses.runs { + for hash := range r.hashes { + delete(p.pool, hash) + } + } + } + + // Expire PRs and remove identity tracking + for hash, pr := range p.prs { + if pr.Expiry > height { + continue + } + + delete(p.prs, hash) + delete(p.messagesByIdentity, pr.Identity) + } +} + +// Received is a parameter for Pool.Receive describing the session and run to +// receive messages for, and maps for returning results. If a slice is nil, no +// messages of that type are received. Received messages are unsorted. +type Received struct { + Sid [32]byte + Run uint32 + KEs []*wire.MsgMixKE + CTs []*wire.MsgMixCT + SRs []*wire.MsgMixSR + DCs []*wire.MsgMixDC + CMs []*wire.MsgMixCM + // XXX: RSs []*wire.MsgMixRS +} + +// Receive returns messages matching a session, run, and message type, waiting +// until all described messages have been received, or earlier with the messages +// received so far if the context is cancelled before this point. +func (p *Pool) Receive(ctx context.Context, r *Received) error { + sid := r.Sid + run := r.Run + var bc *broadcast + + p.mtx.RLock() + ses, ok := p.sessions[sid] + if !ok { + p.mtx.Unlock() + return fmt.Errorf("unknown session") + } + bc = &ses.bc + p.mtx.RUnlock() + + var rs *runstate + var err error + for { + select { + case <-ctx.Done(): + err = ctx.Err() + case <-bc.wait(): + } + + p.mtx.RLock() + if run >= uint32(len(ses.runs)) { + p.mtx.RUnlock() + continue + } + break + } + + rs = &ses.runs[run] + for hash := range rs.hashes { + msg := p.pool[hash].msg + switch msg := msg.(type) { + case *wire.MsgMixKE: + if r.KEs != nil { + r.KEs = append(r.KEs, msg) + } + case *wire.MsgMixCT: + if r.CTs != nil { + r.CTs = append(r.CTs, msg) + } + case *wire.MsgMixSR: + if r.SRs != nil { + r.SRs = append(r.SRs, msg) + } + case *wire.MsgMixDC: + if r.DCs != nil { + r.DCs = append(r.DCs, msg) + } + case *wire.MsgMixCM: + if r.CMs != nil { + r.CMs = append(r.CMs, msg) + } + + // XXX: case *wire.MsgMixRS: + } + } + + p.mtx.RUnlock() + + return err +} + +// AcceptMessage accepts a mixing message to the pool. +// +// Messages must contain the mixing participant's identity and contain a valid +// signature committing to all non-signature fields. +// +// PR messages will not be accepted if they reference an unknown UTXO or if not +// enough fee is contributed. Any other message will not be accepted if it +// references previous messages that are not recorded by the pool. +func (p *Pool) AcceptMessage(msg Message) error { + // Check if already accepted. + hash := msg.Hash() + p.mtx.RLock() + _, ok := p.pool[hash] + p.mtx.RUnlock() + if ok { + return nil + } + + // Require message to be signed by the presented identity. + if !mixing.VerifyMessageSignature(msg) { + return fmt.Errorf("message signature is invalid") + } + id := (*idPubKey)(msg.GetIdentity()) + + // Check that expiry has not been reached, nor that it is too far + // into the future. This limits replay attacks. + // XXX could cache the best known header/height as it is updated to + // prune expired messages. + _, curHeight := p.blockchain.BestHeader() + err := checkExpiry(msg, curHeight, p.params) + if err != nil { + return err + } + + var msgtype int + switch msg := msg.(type) { + case *wire.MsgMixPR: + return p.acceptPR(msg, &hash, id) + + case *wire.MsgMixKE: + return p.acceptKE(msg, &hash, id) + + case *wire.MsgMixCT: + msgtype = msgtypeCT + case *wire.MsgMixSR: + msgtype = msgtypeSR + case *wire.MsgMixDC: + msgtype = msgtypeDC + case *wire.MsgMixCM: + msgtype = msgtypeCM + default: + return fmt.Errorf("unknown mix message type %T", msg) + } + + sid := *(*[32]byte)(msg.Sid()) + + p.mtx.Lock() + defer p.mtx.Unlock() + + // Check if already accepted. + if _, ok := p.pool[hash]; ok { + return nil + } + + // Check prior message existence in the pool, and only accept messages + // that reference other known and accepted messages of the correct type + // and sid. + // + // XXX This could return an error containing the unknown messages, so + // they can be getdata'd, and if they are not received or are garbage, + // peers can be kicked. + prevMsgs := msg.PrevMsgs() + prevEntries := make([]entry, 0, len(prevMsgs)) + for i := range prevMsgs { + looktype := msgtype - 1 + if msgtype == msgtypeRS { + looktype = 0 + } + e, ok := p.lookupEntry(prevMsgs[i], looktype, &sid) + if !ok { + return fmt.Errorf("reference to unknown previous " + + "message") + } + + prevEntries = append(prevEntries, e) + } + + // Check that a message from this identity does not reuse a run number + // for the session. + for _, prevHash := range p.messagesByIdentity[*id] { + e := p.pool[prevHash] + if e.msgtype == msgtype && e.msg.GetRun() == msg.GetRun() && + bytes.Equal(e.msg.Sid(), msg.Sid()) { + return fmt.Errorf("reused run number from identity") + } + } + + ses := p.sessions[sid] + if ses == nil { + return fmt.Errorf("message belongs to unknown session") + } + + return p.acceptEntry(msg, msgtype, &hash, id, ses) +} + +func checkExpiry(msg Message, curHeight int64, params *chaincfg.Params) error { + expires := msg.Expires() + target := params.TargetTimePerBlock + maxExpiry := curHeight + int64(2*time.Hour/target+target) + switch { + case curHeight >= expires: + return fmt.Errorf("message has expired") + case expires > maxExpiry: + return fmt.Errorf("expiry is too far into future") + } + return nil +} + +func (p *Pool) acceptPR(pr *wire.MsgMixPR, hash *chainhash.Hash, id *idPubKey) error { + if len(pr.UTXOs) == 0 { + return fmt.Errorf("at least one UTXO must be submitted") + } + + // If able, sanity check UTXOs. + if p.utxoFetcher != nil { + err := p.checkUTXOs(pr) + if err != nil { + return err + } + } + + // Require known script classes. + switch mixing.ScriptClass(pr.ScriptClass) { + case mixing.ScriptClassP2PKHv0: + default: + return fmt.Errorf("unsupported mixing script class") + } + + // Require enough fee contributed from this mixing participant. + // Size estimation assumes mixing.ScriptClassP2PKHv0 outputs and inputs. + err := checkFee(pr, p.feeRate) + if err != nil { + return err + } + + p.mtx.Lock() + defer p.mtx.Unlock() + + // Check if already accepted. + if _, ok := p.pool[*hash]; ok { + return nil + } + + // Discourage identity reuse. PRs should be the first message sent by + // this identity, and there should only be one PR per identity. + if len(p.messagesByIdentity[*id]) != 0 { + return fmt.Errorf("identity reused for a PR message") + } + + // Accept the PR + p.prs[*hash] = pr + p.messagesByIdentity[*id] = append(make([]chainhash.Hash, 0, 16), *hash) + + return nil +} + +// Check that UTXOs exist, have confirmations, sum of UTXO values matches the +// input value, and proof of ownership is valid. +func (p *Pool) checkUTXOs(pr *wire.MsgMixPR) error { + var totalValue int64 + _, curHeight := p.blockchain.BestHeader() + + for i := range pr.UTXOs { + utxo := &pr.UTXOs[i] + entry, err := p.utxoFetcher.FetchUtxoEntry(utxo.OutPoint) + if err != nil { + return err + } + if entry.IsSpent() { + return fmt.Errorf("output is not unspent") + } + height := entry.BlockHeight() + if !confirmed(height, curHeight, minconf) { + return fmt.Errorf("output is unconfirmed") + } + + // Check proof of key ownership and ability to sign coinjoin + // inputs. + utxoPkScript := entry.PkScript() + var valid bool + switch { + case stdscript.IsPubKeyHashScriptV0(utxoPkScript): + valid = validOwnerProofP2PKHv0(utxoPkScript, + utxo.PubKey, utxo.Signature, pr.Expires()) + default: + return fmt.Errorf("unsupported UTXO output script") + } + if !valid { + return fmt.Errorf("invalid UTXO ownership proof") + } + + totalValue += entry.Amount() + } + + if totalValue != pr.InputValue { + return fmt.Errorf("input value does not match sum of UTXO " + + "values") + } + + return nil +} + +// Tags prepended to signed ownership proof messages. +const ( + ownerproofP2PKHv0 = "mixpr-ownerproof-P2PKH-secp256k1-v0-" +) + +func validOwnerProofP2PKHv0(pkscript, serializedPubkey, serializedSig []byte, + expires int64) bool { + scriptHash160 := stdscript.ExtractPubKeyHashV0(pkscript) + pubkeyHash160 := dcrutil.Hash160(serializedPubkey) + if !bytes.Equal(scriptHash160, pubkeyHash160) { + return false + } + + pubkey, err := secp256k1.ParsePubKey(serializedPubkey) + if err != nil { + return false + } + sig, err := ecdsa.ParseDERSignature(serializedSig) + if err != nil { + return false + } + + h := blake256.New() + h.Write([]byte(ownerproofP2PKHv0)) + h.Write(serializedPubkey) + expiresBytes := binary.BigEndian.AppendUint64(make([]byte, 0, 8), + uint64(expires)) + h.Write(expiresBytes) + hash := h.Sum(nil) + + return sig.Verify(hash, pubkey) +} + +func (p *Pool) acceptKE(ke *wire.MsgMixKE, hash *chainhash.Hash, id *idPubKey) error { + sid := ke.SessionID + + // In all runs, previous PR messages in the KE must be sorted. + // This defines the initial unmixed peer positions. + sorted := sort.SliceIsSorted(ke.SeenPRs, func(i, j int) bool { + a := ke.SeenPRs[i][:] + b := ke.SeenPRs[j][:] + return bytes.Compare(a, b) == -1 + }) + if !sorted { + return fmt.Errorf("KE message contains unsorted previous PR " + + "hashes") + } + + // Run-0 KE messages define a session ID by hashing all previously-seen + // PR message hashes. This must match the sid also present in the + // message. Later runs after a failed run may drop peers from the + // SeenPRs set, but the sid remains the same. A sid can not be conjured + // out of thin air, and other messages seen from the network for an + // unknown session are not accepted. + if ke.Run == 0 { + h := blake256.New() + for i := range ke.SeenPRs { + h.Write(ke.SeenPRs[i][:]) + } + derivedSid := (*[32]byte)(h.Sum(nil)) + if sid != *derivedSid { + return fmt.Errorf("invalid session ID for run-0 KE") + } + } + + p.mtx.Lock() + defer p.mtx.Unlock() + + // Check if already accepted. + if _, ok := p.pool[*hash]; ok { + return nil + } + + // Only accept messages that reference known PRs. + // + // XXX This could return an error containing the unknown messages, so + // they can be getdata'd, and if they are not received or are garbage, + // peers can be kicked. + prevMsgs := ke.PrevMsgs() + prs := make([]*wire.MsgMixPR, len(prevMsgs)) + for i, prevHash := range prevMsgs { + pr, ok := p.prs[prevHash] + if !ok { + return fmt.Errorf("reference to unknown PR") + } + prs[i] = pr + } + + ses := p.sessions[sid] + + // Create a session for the first run-0 KE + if ses == nil { + if ke.Run != 0 { + return fmt.Errorf("unknown session for run-%d KE", + ke.Run) + } + + expiry := int64(1<<63 - 1) + hashes := make(map[chainhash.Hash]struct{}) + for i := range prevMsgs { + hashes[prevMsgs[i]] = struct{}{} + prExpiry := prs[i].Expires() + if expiry > prExpiry { + expiry = prExpiry + } + } + ses = &session{ + sid: sid, + runs: make([]runstate, 0, 4), + expiry: expiry, + bc: broadcast{ch: make(chan struct{})}, + } + p.sessions[sid] = ses + } + + return p.acceptEntry(ke, msgtypeKE, hash, id, ses) +} + +func (p *Pool) acceptEntry(msg Message, msgtype int, hash *chainhash.Hash, + id *[32]byte, ses *session) error { + if msg.Expires() != ses.expiry { + return fmt.Errorf("message has inappropriate expiry") + } + + run := msg.GetRun() + if msg.GetRun() > uint32(len(ses.runs)) { + return fmt.Errorf("message skips runs") + } + if msgtype == msgtypeKE && msg.GetRun() == uint32(len(ses.runs)) { + // Add a runstate for the next run. + rs := runstate{ + run: msg.GetRun(), + npeers: uint32(len(msg.PrevMsgs())), + hashes: map[chainhash.Hash]struct{}{*hash: struct{}{}}, + } + ses.runs = append(ses.runs, rs) + } else { + // Add to existing runstate + rs := &ses.runs[run] + rs.hashes[*hash] = struct{}{} + count := &rs.counts[msgtype-1] // msgtypes start at 1 + *count++ + if *count == rs.npeers { + ses.bc.signal() + } + } + + e := entry{ + hash: *hash, + sid: ses.sid, + msg: msg, + msgtype: msgtype, + run: msg.GetRun(), + } + p.pool[*hash] = e + p.messagesByIdentity[*id] = append(p.messagesByIdentity[*id], *hash) + + return nil +} + +// lookupEntry returns the message entry matching a message hash with msgtype +// and session id. If msgtype is zero, any message type can be looked up. +func (p *Pool) lookupEntry(hash chainhash.Hash, msgtype int, sid *[32]byte) (entry, bool) { + e, ok := p.pool[hash] + if !ok { + return entry{}, false + } + if msgtype != 0 && e.msgtype != msgtype { + return entry{}, false + } + if e.sid != *sid { + return entry{}, false + } + + return e, true +} + +func confirmed(minConf, txHeight, curHeight int64) bool { + return confirms(txHeight, curHeight) >= minConf +} +func confirms(txHeight, curHeight int64) int64 { + switch { + case txHeight == -1, txHeight > curHeight: + return 0 + default: + return curHeight - txHeight + 1 + } +} + +func checkFee(pr *wire.MsgMixPR, feeRate int64) error { + fee := pr.InputValue - int64(pr.MessageCount)*pr.MixAmount + if pr.Change != nil { + fee -= pr.Change.Value + } + + estimatedSize := estimateP2PKHv0SerializeSize(len(pr.UTXOs), + int(pr.MessageCount), pr.Change != nil) + requiredFee := feeForSerializeSize(feeRate, estimatedSize) + if fee < requiredFee { + return fmt.Errorf("not enough input value, or too low fee") + } + + return nil +} + +func feeForSerializeSize(relayFeePerKb int64, txSerializeSize int) int64 { + fee := relayFeePerKb * int64(txSerializeSize) / 1000 + + if fee == 0 && relayFeePerKb > 0 { + fee = relayFeePerKb + } + + const maxAmount = 21e6 * 1e8 + if fee < 0 || fee > maxAmount { + fee = maxAmount + } + + return fee +} + +const ( + redeemP2PKHv0SigScriptSize = 1 + 73 + 1 + 33 + p2pkhv0PkScriptSize = 1 + 1 + 1 + 20 + 1 + 1 +) + +func estimateP2PKHv0SerializeSize(inputs, outputs int, hasChange bool) int { + // Sum the estimated sizes of the inputs and outputs. + txInsSize := inputs * estimateInputSize(redeemP2PKHv0SigScriptSize) + txOutsSize := outputs * estimateOutputSize(p2pkhv0PkScriptSize) + + changeSize := 0 + if hasChange { + changeSize = estimateOutputSize(p2pkhv0PkScriptSize) + outputs++ + } + + // 12 additional bytes are for version, locktime and expiry. + return 12 + (2 * wire.VarIntSerializeSize(uint64(inputs))) + + wire.VarIntSerializeSize(uint64(outputs)) + + txInsSize + txOutsSize + changeSize +} + +// estimateInputSize returns the worst case serialize size estimate for a tx input +func estimateInputSize(scriptSize int) int { + return 32 + // previous tx + 4 + // output index + 1 + // tree + 8 + // amount + 4 + // block height + 4 + // block index + wire.VarIntSerializeSize(uint64(scriptSize)) + // size of script + scriptSize + // script itself + 4 // sequence +} + +// estimateOutputSize returns the worst case serialize size estimate for a tx output +func estimateOutputSize(scriptSize int) int { + return 8 + // previous tx + 2 + // version + wire.VarIntSerializeSize(uint64(scriptSize)) + // size of script + scriptSize // script itself +} diff --git a/mixing/scriptclass.go b/mixing/scriptclass.go new file mode 100644 index 0000000000..849d67d8d4 --- /dev/null +++ b/mixing/scriptclass.go @@ -0,0 +1,12 @@ +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/signatures.go b/mixing/signatures.go new file mode 100644 index 0000000000..6b3911b595 --- /dev/null +++ b/mixing/signatures.go @@ -0,0 +1,58 @@ +// 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 mixing + +import ( + "crypto/ed25519" + "io" + + "github.com/decred/dcrd/crypto/blake256" +) + +// XXX: can/should the identity keys be replaced with schnorr? + +type Signed interface { + GetIdentity() []byte + GetSignature() []byte + WriteSigned(io.Writer) error +} + +func SignMessage(sk ed25519.PrivateKey, m Signed) error { + sig, err := sign(sk, m) + if err != nil { + return err + } + copy(m.GetSignature(), sig) + return nil +} + +func VerifyMessageSignature(m Signed) bool { + return verify(m.GetIdentity(), m, m.GetSignature()) +} + +func sign(sk ed25519.PrivateKey, m Signed) ([]byte, error) { + if len(sk) == 0 { + return nil, nil + } + h := blake256.New() + err := m.WriteSigned(h) + if err != nil { + return nil, err + } + sig := ed25519.Sign(sk, h.Sum(nil)) + return sig, nil +} + +func verify(pk ed25519.PublicKey, m Signed, sig []byte) bool { + if len(sig) != ed25519.SignatureSize { + return false + } + h := blake256.New() + err := m.WriteSigned(h) + if err != nil { + return false + } + return ed25519.Verify(pk, h.Sum(nil), sig) +}