Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vsp: Add per ticket authentication #121

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions decred/decred/dcr/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,20 @@ def setNewPool(self, pool):
for utxo in bc.UTXOs([addr]):
self.addUTXO(utxo)
self.updateSpentTickets(checkTxids)
for txid in checkTxids:
tx = None
try:
tx = self.blockchain.tx(txid)
except Exception:
if txid in self.mempool:
tx = self.mempool[txid]
else:
# Should never make it here.
log.error(f"unknown ticket: {txid}")
continue
if tx and tx.isTicket() and txid not in pool.tickets:
pool.tickets.append(txid)
self.vspDB[pool.apiKey] = pool
self.updateStakeStats()
self.signals.balance(self.calcBalance())

Expand Down Expand Up @@ -1880,6 +1894,9 @@ def purchaseTickets(self, qty, price):
# Add all tickets
for tx in txs[1]:
self.addMempoolTx(tx)
if tx.txid() not in pool.tickets:
pool.tickets.append(tx.txid())
self.vspDB[pool.apiKey] = pool
# Store the txids.
self.spendUTXOs(spentUTXOs)
for utxo in newUTXOs:
Expand Down
104 changes: 98 additions & 6 deletions decred/decred/dcr/txscript.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,34 @@
# 01000000 00000000 = Apply fees rule
SStxRevFractionFlag = 0x4000

# compactSigSize is the size of a compact signature. It consists of a
# compact signature recovery code byte followed by the R and S components
# serialized as 32-byte big-endian values. 1+32*2 = 65.
# for the R and S components. 1+32+32=65.
compactSigSize = 65

# compactSigMagicOffset is a value used when creating the compact signature
# recovery code inherited from Bitcoin and has no meaning, but has been
# retained for compatibility. For historical purposes, it was originally
# picked to avoid a binary representation that would allow compact
# signatures to be mistaken for other components.
compactSigMagicOffset = 27

# compactSigCompPubKey is a value used when creating the compact signature
# recovery code to indicate the original public key was compressed.
compactSigCompPubKey = 4

# pubKeyRecoveryCodeOddnessBit specifies the bit that indicates the oddess
# of the Y coordinate of the random point calculated when creating a
# signature.
pubKeyRecoveryCodeOddnessBit = 1 << 0

# pubKeyRecoveryCodeOverflowBit specifies the bit that indicates the X
# coordinate of the random point calculated when creating a signature was
# >= N, where N is the order of the group.
pubKeyRecoveryCodeOverflowBit = 1 << 1


# A couple of hashing functions from the crypto module.
mac = crypto.mac
hashH = crypto.hashH
Expand Down Expand Up @@ -2099,25 +2127,34 @@ def signRFC6979(privateKey, inHash):
"""
N = Curve.N
k = nonceRFC6979(privateKey, inHash, ByteArray(b""), ByteArray(b""))
recoveryCode = 0

inv = crypto.modInv(k, N)
r = Curve.scalarBaseMult(k)[0] % N
kG = Curve.scalarBaseMult(k)
r = kG[0] % N

if r == 0:
raise DecredError("calculated R is zero")

if kG[1] & 1:
recoveryCode += 1
if kG[0] > N:
recoveryCode += 4

e = hashToInt(inHash)
s = privateKey.int() * r
s += e
s *= inv
s = s % N

if (N >> 1) > 1:
s = N - s
if s == 0:
raise DecredError("calculated S is zero")

return Signature(r, s)
if s > N / 2:
s = N - s
recoveryCode ^= 1

return Signature(r, s), recoveryCode


def putVarInt(val):
Expand Down Expand Up @@ -2199,6 +2236,32 @@ def addData(data):
return b


def signCompact(key, inHash, isCompressedKey):
"""
SignCompact produces a compact signature of the data in hash with the given
private key on the secp256k1 curve. The isCompressedKey parameter specifies
if the given signature should reference a compressed public key or not.

Compact signature format:
<1-byte compact sig recovery code><32-byte R><32-byte S>

The compact sig recovery code is the value 27 + public key recovery code + 4
if the compact signature was created with a compressed public key.
"""
# Create the signature and associated pubkey recovery code and calculate
# the compact signature recovery code.
sig, recoveryCode = signRFC6979(key, inHash)
compactSigRecoveryCode = compactSigMagicOffset + recoveryCode
if isCompressedKey:
compactSigRecoveryCode += compactSigCompPubKey

# Output <compactSigRecoveryCode><32-byte R><32-byte S>.
b = ByteArray(compactSigRecoveryCode)
b += ByteArray(sig.r, length=32)
b += ByteArray(sig.s, length=32)
return b


def signatureScript(tx, idx, subscript, hashType, privKey, compress):
"""
SignatureScript creates an input signature script for tx to spend coins sent
Expand Down Expand Up @@ -2236,8 +2299,8 @@ def rawTxInSignature(tx, idx, subScript, hashType, key):
versions.
"""
sigHash = calcSignatureHash(subScript, hashType, tx, idx, None)
sig = signRFC6979(key, sigHash).serialize()
return sig + ByteArray(hashType)
sig, _ = signRFC6979(key, sigHash)
return sig.serialize() + ByteArray(hashType)


def calcSignatureHash(script, hashType, tx, idx, cachedPrefix):
Expand Down Expand Up @@ -2717,6 +2780,35 @@ def extractPkScriptAddrs(version, pkScript, netParams):
return NonStandardTy, [], 0


def addrFromSStxPkScrCommitment(pkScript, netParams):
"""
AddrFromSStxPkScrCommitment extracts a P2SH or P2PKH address from a ticket
commitment pkScript.
"""
if len(pkScript) < SStxPKHMinOutSize:
raise DecredError("short read of sstx commit pkscript")

# The MSB of the encoded amount specifies if the output is P2SH. Since
# it is encoded with little endian, the MSB is in final byte in the encoded
# amount.
#
# This is a faster equivalent of:
#
# amtBytes := script[22:30]
# amtEncoded := binary.LittleEndian.Uint64(amtBytes)
# isP2SH := (amtEncoded & uint64(1<<63)) != 0
isP2SH = pkScript[29] & 0x80 != 0

# The 20 byte PKH or SH.
hashBytes = pkScript[2:22]

# Return the correct address type.
if isP2SH:
return crypto.newAddressScriptHashFromHash(hashBytes, netParams)

return crypto.newAddressPubKeyHash(hashBytes, netParams, crypto.STEcdsaSecp256k1)


def sign(chainParams, tx, idx, subScript, hashType, keysource, sigType):
scriptClass, addresses, nrequired = extractPkScriptAddrs(
DefaultScriptVersion, subScript, chainParams
Expand Down
63 changes: 59 additions & 4 deletions decred/decred/dcr/vsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
DcrdataClient.endpointList() for available endpoints.
"""

import base64
import time

from decred import DecredError
Expand Down Expand Up @@ -159,7 +160,7 @@ class VotingServiceProvider(object):
the VSP API.
"""

def __init__(self, url, apiKey, netName, purchaseInfo=None):
def __init__(self, url, apiKey, netName, purchaseInfo=None, tickets=None):
"""
Args:
url (string): The stake pool URL.
Expand All @@ -176,6 +177,8 @@ def __init__(self, url, apiKey, netName, purchaseInfo=None):
self.apiKey = apiKey
self.net = nets.parse(netName)
self.purchaseInfo = purchaseInfo
# a list of ticket txid purchased through this vsp
self.tickets = tickets if tickets else []
self.stats = None
self.err = None

Expand All @@ -184,21 +187,22 @@ def blob(vsp):
"""Satisfies the encode.Blobber API"""
pi = PurchaseInfo.blob(vsp.purchaseInfo) if vsp.purchaseInfo else None
return (
encode.BuildyBytes(0)
encode.BuildyBytes(1)
.addData(vsp.url.encode("utf-8"))
.addData(vsp.apiKey.encode("utf-8"))
.addData(vsp.net.Name.encode("utf-8"))
.addData(encode.filterNone(pi))
.addData(encode.blobStrList(vsp.tickets))
.b
)

@staticmethod
def unblob(b):
"""Satisfies the encode.Blobber API"""
ver, d = encode.decodeBlob(b)
if ver != 0:
if ver != 1:
raise AssertionError("invalid version for VotingServiceProvider %d" % ver)
if len(d) != 4:
if len(d) != 5:
raise AssertionError(
"wrong number of pushes for VotingServiceProvider. wanted 4, got %d"
% len(d)
Expand All @@ -212,6 +216,7 @@ def unblob(b):
apiKey=d[1].decode("utf-8"),
netName=d[2].decode("utf-8"),
purchaseInfo=pi,
tickets=encode.unblobStrList(d[4]),
)

def serialize(self):
Expand Down Expand Up @@ -259,6 +264,28 @@ def headers(self):
"""
return {"Authorization": "Bearer %s" % self.apiKey}

def headersV3(self, acct, txid):
"""
Make the API request headers.

Returns:
object: The headers as a Python object.
"""
now = str(int(time.time()))
txOut = acct.blockchain.tx(txid).txOut[3]
addr = txscript.addrFromSStxPkScrCommitment(txOut.pkScript, acct.net)
msg = "Decred Signed Message:\n"
msgBA = txscript.putVarInt(len(msg))
msgBA += ByteArray(msg.encode())
msgBA += txscript.putVarInt(len(now))
msgBA += ByteArray(now.encode())
hashedMsg = crypto.hashH(msgBA.bytes())
sig = self.getSignature(acct, hashedMsg.bytes(), addr)
b64Sig = base64.b64encode(sig.bytes())
return {
"Authorization": f'TicketAuth SignedTimestamp={now},Signature={b64Sig.decode("utf-8")},TicketHash={txid}'
}

def validate(self, addr):
"""
Validate performs some checks that the PurchaseInfo provided by the
Expand Down Expand Up @@ -342,6 +369,14 @@ def getPurchaseInfo(self):
self.err = res
raise DecredError("unexpected response from 'getpurchaseinfo': %r" % (res,))

def getSignature(self, acct, msg, addr):
"""
Sign msg with the private key belonging to addr.
"""
privKey = acct.privKeyForAddress(addr.string())
sig = txscript.signCompact(privKey.key, msg, True)
return sig

def updatePurchaseInfo(self):
"""
Update purchase info if older than PURCHASE_INFO_LIFE.
Expand All @@ -362,6 +397,26 @@ def getStats(self):
return self.stats
raise DecredError("unexpected response from 'stats': %s" % repr(res))

def setVoteBitsV3(self, voteBits, acct):
"""
Set the vote preference on the VotingServiceProvider.

Returns:
bool: True on success. DecredError raised on error.
"""
txid = self.tickets[0]
data = {"VoteBits": voteBits}
res = tinyhttp.post(
self.apiPath("voting"),
data,
headers=self.headersV3(acct, txid),
urlEncode=True,
)
if resultIsSuccess(res):
self.purchaseInfo.voteBits = voteBits
return True
raise DecredError("unexpected response from 'voting': %s" % repr(res))

def setVoteBits(self, voteBits):
"""
Set the vote preference on the VotingServiceProvider.
Expand Down
2 changes: 1 addition & 1 deletion tinywallet/tinywallet/screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -2173,7 +2173,7 @@ def func(idx):
def changeVote():
app.emitSignal(ui.WORKING_SIGNAL)
try:
pools[0].setVoteBits(voteBits)
pools[0].setVoteBitsV3(voteBits, acct)
app.appWindow.showSuccess("vote choices updated")
dropdown.lastIndex = idx
except Exception as e:
Expand Down