1
0
mirror of https://github.com/bigchaindb/bigchaindb.git synced 2024-06-28 00:27:45 +02:00

Merge pull request #1213 from bigchaindb/identify-voters

Identify voters + Voting class
This commit is contained in:
libscott 2017-03-07 14:06:22 +01:00 committed by GitHub
commit 9ec9f638fc
2 changed files with 361 additions and 0 deletions

158
bigchaindb/voting.py Normal file
View File

@ -0,0 +1,158 @@
import collections
from bigchaindb.common.schema import SchemaValidationError, validate_vote_schema
from bigchaindb.common.utils import serialize
from bigchaindb.common.crypto import PublicKey
VALID = 'valid'
INVALID = 'invalid'
UNDECIDED = 'undecided'
class Voting:
"""
Everything to do with creating and checking votes.
All functions in this class should be referentially transparent, that is,
they always give the same output for a given input. This makes it easier
to test. This also means no logging!
Assumptions regarding data:
* Vote is a dictionary, but it is not assumed that any properties are.
* Everything else is assumed to be structurally correct, otherwise errors
may be thrown.
"""
@classmethod
def block_election(cls, block, votes, keyring):
"""
Calculate the election status of a block.
"""
eligible_voters = set(block['block']['voters']) & set(keyring)
eligible_votes, ineligible_votes = \
cls.partition_eligible_votes(votes, eligible_voters)
n_voters = len(eligible_voters)
results = cls.count_votes(eligible_votes)
results['status'] = cls.decide_votes(n_voters, **results['counts'])
results['ineligible'] = ineligible_votes
return results
@classmethod
def partition_eligible_votes(cls, votes, eligible_voters):
"""
Filter votes from unknown nodes or nodes that are not listed on
block. This is the primary Sybill protection.
"""
eligible, ineligible = ([], [])
for vote in votes:
voter_eligible = vote.get('node_pubkey') in eligible_voters
if voter_eligible:
try:
if cls.verify_vote_signature(vote):
eligible.append(vote)
continue
except ValueError:
pass
ineligible.append(vote)
return eligible, ineligible
@classmethod
def count_votes(cls, eligible_votes):
"""
Given a list of eligible votes, (votes from known nodes that are listed
as voters), produce the number that say valid and the number that say
invalid.
* Detect if there are multiple votes from a single node and return them
in a separate "cheat" dictionary.
* Votes must agree on previous block, otherwise they become invalid.
note:
The sum of votes returned by this function does not necessarily
equal the length of the list of votes fed in. It may differ for
example if there are found to be multiple votes submitted by a
single voter.
"""
prev_blocks = collections.Counter()
cheat = []
malformed = []
# Group by pubkey to detect duplicate voting
by_voter = collections.defaultdict(list)
for vote in eligible_votes:
by_voter[vote['node_pubkey']].append(vote)
for pubkey, votes in by_voter.items():
if len(votes) > 1:
cheat.append(votes)
continue
vote = votes[0]
if not cls.verify_vote_schema(vote):
malformed.append(vote)
continue
if vote['vote']['is_block_valid'] is True:
prev_blocks[vote['vote']['previous_block']] += 1
n_valid = 0
prev_block = None
# Valid votes must agree on previous block
if prev_blocks:
prev_block, n_valid = prev_blocks.most_common()[0]
del prev_blocks[prev_block]
return {
'counts': {
'n_valid': n_valid,
'n_invalid': len(by_voter) - n_valid,
},
'cheat': cheat,
'malformed': malformed,
'previous_block': prev_block,
'other_previous_block': dict(prev_blocks),
}
@classmethod
def decide_votes(cls, n_voters, n_valid, n_invalid):
"""
Decide on votes.
To return VALID there must be a clear majority that say VALID
and also agree on the previous block.
A tie on an even number of votes counts as INVALID.
"""
if n_invalid * 2 >= n_voters:
return INVALID
if n_valid * 2 > n_voters:
return VALID
return UNDECIDED
@classmethod
def verify_vote_signature(cls, vote):
"""
Verify the signature of a vote
"""
signature = vote.get('signature')
pk_base58 = vote.get('node_pubkey')
if not (type(signature) == str and type(pk_base58) == str):
raise ValueError('Malformed vote: %s' % vote)
public_key = PublicKey(pk_base58)
body = serialize(vote['vote']).encode()
return public_key.verify(body, signature)
@classmethod
def verify_vote_schema(cls, vote):
# I'm not sure this is the correct approach. Maybe we should allow
# duck typing w/r/t votes.
try:
validate_vote_schema(vote)
return True
except SchemaValidationError as e:
return False

203
tests/test_voting.py Normal file
View File

@ -0,0 +1,203 @@
import pytest
from collections import Counter
from bigchaindb.core import Bigchain
from bigchaindb.voting import Voting, INVALID, VALID, UNDECIDED
################################################################################
# Tests for checking vote eligibility
def test_partition_eligible_votes():
class TestVoting(Voting):
@classmethod
def verify_vote_signature(cls, vote):
if vote['node_pubkey'] == 'invalid sig':
return False
if vote['node_pubkey'] == 'value error':
raise ValueError()
return True
voters = ['valid', 'invalid sig', 'value error', 'not in set']
votes = [{'node_pubkey': k} for k in voters]
el, inel = TestVoting.partition_eligible_votes(votes, voters[:-1])
assert el == [votes[0]]
assert inel == votes[1:]
################################################################################
# Test vote counting
def test_count_votes():
class TestVoting(Voting):
@classmethod
def verify_vote_schema(cls, vote):
return vote['node_pubkey'] != 'malformed'
voters = (['cheat', 'cheat', 'says invalid', 'malformed'] +
['kosher' + str(i) for i in range(10)])
votes = [Bigchain(v).vote('block', 'a', True) for v in voters]
votes[2]['vote']['is_block_valid'] = False
# Incorrect previous block subtracts from n_valid and adds to n_invalid
votes[-1]['vote']['previous_block'] = 'z'
assert TestVoting.count_votes(votes) == {
'counts': {
'n_valid': 9, # 9 kosher votes
'n_invalid': 4, # 1 cheat, 1 invalid, 1 malformed, 1 rogue prev block
# One of the cheat votes counts towards n_invalid, the other is
# not counted here.
# len(cheat) + n_valid + n_invalid == len(votes)
},
'cheat': [votes[:2]],
'malformed': [votes[3]],
'previous_block': 'a',
'other_previous_block': {'z': 1},
}
def test_must_agree_prev_block():
class TestVoting(Voting):
@classmethod
def verify_vote_schema(cls, vote):
return True
voters = 'abcd'
votes = [Bigchain(v).vote('block', 'a', True) for v in voters]
votes[0]['vote']['previous_block'] = 'b'
votes[1]['vote']['previous_block'] = 'c'
assert TestVoting.count_votes(votes) == {
'counts': {
'n_valid': 2,
'n_invalid': 2,
},
'previous_block': 'a',
'other_previous_block': {'b': 1, 'c': 1},
'malformed': [],
'cheat': [],
}
################################################################################
# Tests for vote decision making
DECISION_TESTS = [
{'n_voters': 1, 'n_valid': 1, 'n_invalid': 1},
{'n_voters': 2, 'n_valid': 2, 'n_invalid': 1},
{'n_voters': 3, 'n_valid': 2, 'n_invalid': 2},
{'n_voters': 4, 'n_valid': 3, 'n_invalid': 2},
{'n_voters': 5, 'n_valid': 3, 'n_invalid': 3},
{'n_voters': 6, 'n_valid': 4, 'n_invalid': 3},
{'n_voters': 7, 'n_valid': 4, 'n_invalid': 4},
{'n_voters': 8, 'n_valid': 5, 'n_invalid': 4}
]
@pytest.mark.parametrize('kwargs', DECISION_TESTS)
def test_decide_votes_valid(kwargs):
kwargs = kwargs.copy()
kwargs['n_invalid'] = 0
assert Voting.decide_votes(**kwargs) == VALID
kwargs['n_valid'] -= 1
assert Voting.decide_votes(**kwargs) == UNDECIDED
@pytest.mark.parametrize('kwargs', DECISION_TESTS)
def test_decide_votes_invalid(kwargs):
kwargs = kwargs.copy()
kwargs['n_valid'] = 0
assert Voting.decide_votes(**kwargs) == INVALID
kwargs['n_invalid'] -= 1
assert Voting.decide_votes(**kwargs) == UNDECIDED
################################################################################
# Actions - test state transitions
@pytest.mark.parametrize('n_voters', range(8))
def test_vote_actions(n_voters):
"""
* Legal transitions are UNDECIDED -> [VALID|INVALID] only
* Block is never left UNDECIDED after voting
* Accomodates rogues on previous block / invalid schema
"""
class TestVoting(Voting):
@classmethod
def verify_vote_schema(cls, vote):
return type(vote['vote']['is_block_valid']) == bool
@classmethod
def verify_vote_signature(cls, vote):
return True
keyring = 'abcdefghijklmnopqrstuvwxyz'[:n_voters]
block = {'id': 'block', 'block': {'voters': keyring}}
state = UNDECIDED
todo = [(state, [], [])]
def branch(p, r):
todo.append((state, votes, votes + [{
'node_pubkey': keyring[len(votes)],
'vote': {'previous_block': p, 'is_block_valid': r}
}]))
while todo:
prev_state, prev_votes, votes = todo.pop(0)
results = Counter(v['vote']['is_block_valid'] for v in votes)
prev_blocks = Counter(v['vote']['previous_block'] for v in votes)
majority = n_voters // 2 + 1
honest = (len(votes) == majority and len(prev_blocks) == 1 and
not results['lol'] and len(results) == 1)
closed = len(votes) == n_voters
# Test legal transition
if votes:
state = TestVoting.block_election(block, votes, keyring)['status']
assert prev_state in [state, UNDECIDED]
# Test that decision has been reached
if honest or closed:
assert state != UNDECIDED or n_voters == 0
if closed:
continue
# Can accomodate more votes, add them to the todo list.
# This vote is the good case
branch('A', True)
# This vote disagrees on previous block
branch('B', True)
# This vote says the block is invalid
branch('A', False)
# This vote is invalid
branch('A', 'lol')
################################################################################
# Tests for vote signature
def test_verify_vote_signature_passes(b):
vote = b.vote('block', 'a', True)
assert Voting.verify_vote_signature(vote)
vote['signature'] = ''
assert not Voting.verify_vote_signature(vote)
################################################################################
# Tests for vote schema
def test_verify_vote_schema(b):
vote = b.vote('b' * 64, 'a' * 64, True)
assert Voting.verify_vote_schema(vote)
vote = b.vote('b' * 64, 'a', True)
assert not Voting.verify_vote_schema(vote)
vote = b.vote('b', 'a' * 64, True)
assert not Voting.verify_vote_schema(vote)