Skip to content

Commit acd2f92

Browse files
committed
vsp: Add per ticket authentication
Add the ability to change vote preverences using a new authorization scheme. A timestamp signed with a tickets's user commitment address's private key is used to prove ownership of a ticket. This data is sent using a different header and comma separated values.
1 parent 13b0c81 commit acd2f92

File tree

4 files changed

+175
-11
lines changed

4 files changed

+175
-11
lines changed

decred/decred/dcr/account.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,6 +1733,20 @@ def setNewPool(self, pool):
17331733
for utxo in bc.UTXOs([addr]):
17341734
self.addUTXO(utxo)
17351735
self.updateSpentTickets(checkTxids)
1736+
for txid in checkTxids:
1737+
tx = None
1738+
try:
1739+
tx = self.blockchain.tx(txid)
1740+
except Exception:
1741+
if txid in self.mempool:
1742+
tx = self.mempool[txid]
1743+
else:
1744+
# Should never make it here.
1745+
log.error(f"unknown ticket: {txid}")
1746+
continue
1747+
if tx and tx.isTicket() and txid not in pool.tickets:
1748+
pool.tickets.append(txid)
1749+
self.vspDB[pool.apiKey] = pool
17361750
self.updateStakeStats()
17371751
self.signals.balance(self.calcBalance())
17381752

@@ -1880,6 +1894,9 @@ def purchaseTickets(self, qty, price):
18801894
# Add all tickets
18811895
for tx in txs[1]:
18821896
self.addMempoolTx(tx)
1897+
if tx.txid() not in pool.tickets:
1898+
pool.tickets.append(tx.txid())
1899+
self.vspDB[pool.apiKey] = pool
18831900
# Store the txids.
18841901
self.spendUTXOs(spentUTXOs)
18851902
for utxo in newUTXOs:

decred/decred/dcr/txscript.py

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,34 @@
294294
# 01000000 00000000 = Apply fees rule
295295
SStxRevFractionFlag = 0x4000
296296

297+
# compactSigSize is the size of a compact signature. It consists of a
298+
# compact signature recovery code byte followed by the R and S components
299+
# serialized as 32-byte big-endian values. 1+32*2 = 65.
300+
# for the R and S components. 1+32+32=65.
301+
compactSigSize = 65
302+
303+
# compactSigMagicOffset is a value used when creating the compact signature
304+
# recovery code inherited from Bitcoin and has no meaning, but has been
305+
# retained for compatibility. For historical purposes, it was originally
306+
# picked to avoid a binary representation that would allow compact
307+
# signatures to be mistaken for other components.
308+
compactSigMagicOffset = 27
309+
310+
# compactSigCompPubKey is a value used when creating the compact signature
311+
# recovery code to indicate the original public key was compressed.
312+
compactSigCompPubKey = 4
313+
314+
# pubKeyRecoveryCodeOddnessBit specifies the bit that indicates the oddess
315+
# of the Y coordinate of the random point calculated when creating a
316+
# signature.
317+
pubKeyRecoveryCodeOddnessBit = 1 << 0
318+
319+
# pubKeyRecoveryCodeOverflowBit specifies the bit that indicates the X
320+
# coordinate of the random point calculated when creating a signature was
321+
# >= N, where N is the order of the group.
322+
pubKeyRecoveryCodeOverflowBit = 1 << 1
323+
324+
297325
# A couple of hashing functions from the crypto module.
298326
mac = crypto.mac
299327
hashH = crypto.hashH
@@ -2099,25 +2127,34 @@ def signRFC6979(privateKey, inHash):
20992127
"""
21002128
N = Curve.N
21012129
k = nonceRFC6979(privateKey, inHash, ByteArray(b""), ByteArray(b""))
2130+
recoveryCode = 0
21022131

21032132
inv = crypto.modInv(k, N)
2104-
r = Curve.scalarBaseMult(k)[0] % N
2133+
kG = Curve.scalarBaseMult(k)
2134+
r = kG[0] % N
21052135

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

2139+
if kG[1] & 1:
2140+
recoveryCode += 1
2141+
if kG[0] > N:
2142+
recoveryCode += 4
2143+
21092144
e = hashToInt(inHash)
21102145
s = privateKey.int() * r
21112146
s += e
21122147
s *= inv
21132148
s = s % N
21142149

2115-
if (N >> 1) > 1:
2116-
s = N - s
21172150
if s == 0:
21182151
raise DecredError("calculated S is zero")
21192152

2120-
return Signature(r, s)
2153+
if s > N / 2:
2154+
s = N - s
2155+
recoveryCode ^= 1
2156+
2157+
return Signature(r, s), recoveryCode
21212158

21222159

21232160
def putVarInt(val):
@@ -2199,6 +2236,32 @@ def addData(data):
21992236
return b
22002237

22012238

2239+
def signCompact(key, inHash, isCompressedKey):
2240+
"""
2241+
SignCompact produces a compact signature of the data in hash with the given
2242+
private key on the secp256k1 curve. The isCompressedKey parameter specifies
2243+
if the given signature should reference a compressed public key or not.
2244+
2245+
Compact signature format:
2246+
<1-byte compact sig recovery code><32-byte R><32-byte S>
2247+
2248+
The compact sig recovery code is the value 27 + public key recovery code + 4
2249+
if the compact signature was created with a compressed public key.
2250+
"""
2251+
# Create the signature and associated pubkey recovery code and calculate
2252+
# the compact signature recovery code.
2253+
sig, recoveryCode = signRFC6979(key, inHash)
2254+
compactSigRecoveryCode = compactSigMagicOffset + recoveryCode
2255+
if isCompressedKey:
2256+
compactSigRecoveryCode += compactSigCompPubKey
2257+
2258+
# Output <compactSigRecoveryCode><32-byte R><32-byte S>.
2259+
b = ByteArray(compactSigRecoveryCode)
2260+
b += ByteArray(sig.r, length=32)
2261+
b += ByteArray(sig.s, length=32)
2262+
return b
2263+
2264+
22022265
def signatureScript(tx, idx, subscript, hashType, privKey, compress):
22032266
"""
22042267
SignatureScript creates an input signature script for tx to spend coins sent
@@ -2236,8 +2299,8 @@ def rawTxInSignature(tx, idx, subScript, hashType, key):
22362299
versions.
22372300
"""
22382301
sigHash = calcSignatureHash(subScript, hashType, tx, idx, None)
2239-
sig = signRFC6979(key, sigHash).serialize()
2240-
return sig + ByteArray(hashType)
2302+
sig, _ = signRFC6979(key, sigHash)
2303+
return sig.serialize() + ByteArray(hashType)
22412304

22422305

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

27192782

2783+
def addrFromSStxPkScrCommitment(pkScript, netParams):
2784+
"""
2785+
AddrFromSStxPkScrCommitment extracts a P2SH or P2PKH address from a ticket
2786+
commitment pkScript.
2787+
"""
2788+
if len(pkScript) < SStxPKHMinOutSize:
2789+
raise DecredError("short read of sstx commit pkscript")
2790+
2791+
# The MSB of the encoded amount specifies if the output is P2SH. Since
2792+
# it is encoded with little endian, the MSB is in final byte in the encoded
2793+
# amount.
2794+
#
2795+
# This is a faster equivalent of:
2796+
#
2797+
# amtBytes := script[22:30]
2798+
# amtEncoded := binary.LittleEndian.Uint64(amtBytes)
2799+
# isP2SH := (amtEncoded & uint64(1<<63)) != 0
2800+
isP2SH = pkScript[29] & 0x80 != 0
2801+
2802+
# The 20 byte PKH or SH.
2803+
hashBytes = pkScript[2:22]
2804+
2805+
# Return the correct address type.
2806+
if isP2SH:
2807+
return crypto.newAddressScriptHashFromHash(hashBytes, netParams)
2808+
2809+
return crypto.newAddressPubKeyHash(hashBytes, netParams, crypto.STEcdsaSecp256k1)
2810+
2811+
27202812
def sign(chainParams, tx, idx, subScript, hashType, keysource, sigType):
27212813
scriptClass, addresses, nrequired = extractPkScriptAddrs(
27222814
DefaultScriptVersion, subScript, chainParams

decred/decred/dcr/vsp.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
DcrdataClient.endpointList() for available endpoints.
77
"""
88

9+
import base64
910
import time
1011

1112
from decred import DecredError
@@ -159,7 +160,7 @@ class VotingServiceProvider(object):
159160
the VSP API.
160161
"""
161162

162-
def __init__(self, url, apiKey, netName, purchaseInfo=None):
163+
def __init__(self, url, apiKey, netName, purchaseInfo=None, tickets=None):
163164
"""
164165
Args:
165166
url (string): The stake pool URL.
@@ -176,6 +177,8 @@ def __init__(self, url, apiKey, netName, purchaseInfo=None):
176177
self.apiKey = apiKey
177178
self.net = nets.parse(netName)
178179
self.purchaseInfo = purchaseInfo
180+
# a list of ticket txid purchased through this vsp
181+
self.tickets = tickets if tickets else []
179182
self.stats = None
180183
self.err = None
181184

@@ -184,21 +187,22 @@ def blob(vsp):
184187
"""Satisfies the encode.Blobber API"""
185188
pi = PurchaseInfo.blob(vsp.purchaseInfo) if vsp.purchaseInfo else None
186189
return (
187-
encode.BuildyBytes(0)
190+
encode.BuildyBytes(1)
188191
.addData(vsp.url.encode("utf-8"))
189192
.addData(vsp.apiKey.encode("utf-8"))
190193
.addData(vsp.net.Name.encode("utf-8"))
191194
.addData(encode.filterNone(pi))
195+
.addData(encode.blobStrList(vsp.tickets))
192196
.b
193197
)
194198

195199
@staticmethod
196200
def unblob(b):
197201
"""Satisfies the encode.Blobber API"""
198202
ver, d = encode.decodeBlob(b)
199-
if ver != 0:
203+
if ver != 1:
200204
raise AssertionError("invalid version for VotingServiceProvider %d" % ver)
201-
if len(d) != 4:
205+
if len(d) != 5:
202206
raise AssertionError(
203207
"wrong number of pushes for VotingServiceProvider. wanted 4, got %d"
204208
% len(d)
@@ -212,6 +216,7 @@ def unblob(b):
212216
apiKey=d[1].decode("utf-8"),
213217
netName=d[2].decode("utf-8"),
214218
purchaseInfo=pi,
219+
tickets=encode.unblobStrList(d[4]),
215220
)
216221

217222
def serialize(self):
@@ -259,6 +264,28 @@ def headers(self):
259264
"""
260265
return {"Authorization": "Bearer %s" % self.apiKey}
261266

267+
def headersV3(self, acct, txid):
268+
"""
269+
Make the API request headers.
270+
271+
Returns:
272+
object: The headers as a Python object.
273+
"""
274+
now = str(int(time.time()))
275+
txOut = acct.blockchain.tx(txid).txOut[3]
276+
addr = txscript.addrFromSStxPkScrCommitment(txOut.pkScript, acct.net)
277+
msg = "Decred Signed Message:\n"
278+
msgBA = txscript.putVarInt(len(msg))
279+
msgBA += ByteArray(msg.encode())
280+
msgBA += txscript.putVarInt(len(now))
281+
msgBA += ByteArray(now.encode())
282+
hashedMsg = crypto.hashH(msgBA.bytes())
283+
sig = self.getSignature(acct, hashedMsg.bytes(), addr)
284+
b64Sig = base64.b64encode(sig.bytes())
285+
return {
286+
"Authorization": f'TicketAuth SignedTimestamp={now},Signature={b64Sig.decode("utf-8")},TicketHash={txid}'
287+
}
288+
262289
def validate(self, addr):
263290
"""
264291
Validate performs some checks that the PurchaseInfo provided by the
@@ -342,6 +369,14 @@ def getPurchaseInfo(self):
342369
self.err = res
343370
raise DecredError("unexpected response from 'getpurchaseinfo': %r" % (res,))
344371

372+
def getSignature(self, acct, msg, addr):
373+
"""
374+
Sign msg with the private key belonging to addr.
375+
"""
376+
privKey = acct.privKeyForAddress(addr.string())
377+
sig = txscript.signCompact(privKey.key, msg, True)
378+
return sig
379+
345380
def updatePurchaseInfo(self):
346381
"""
347382
Update purchase info if older than PURCHASE_INFO_LIFE.
@@ -362,6 +397,26 @@ def getStats(self):
362397
return self.stats
363398
raise DecredError("unexpected response from 'stats': %s" % repr(res))
364399

400+
def setVoteBitsV3(self, voteBits, acct):
401+
"""
402+
Set the vote preference on the VotingServiceProvider.
403+
404+
Returns:
405+
bool: True on success. DecredError raised on error.
406+
"""
407+
txid = self.tickets[0]
408+
data = {"VoteBits": voteBits}
409+
res = tinyhttp.post(
410+
self.apiPath("voting"),
411+
data,
412+
headers=self.headersV3(acct, txid),
413+
urlEncode=True,
414+
)
415+
if resultIsSuccess(res):
416+
self.purchaseInfo.voteBits = voteBits
417+
return True
418+
raise DecredError("unexpected response from 'voting': %s" % repr(res))
419+
365420
def setVoteBits(self, voteBits):
366421
"""
367422
Set the vote preference on the VotingServiceProvider.

tinywallet/tinywallet/screens.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2173,7 +2173,7 @@ def func(idx):
21732173
def changeVote():
21742174
app.emitSignal(ui.WORKING_SIGNAL)
21752175
try:
2176-
pools[0].setVoteBits(voteBits)
2176+
pools[0].setVoteBitsV3(voteBits, acct)
21772177
app.appWindow.showSuccess("vote choices updated")
21782178
dropdown.lastIndex = idx
21792179
except Exception as e:

0 commit comments

Comments
 (0)