blob: 9ce9d2c5a80b1a96a9884ce2ed1796f1f343f495 [file] [log] [blame]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# ----
#
# ### TBD: DOCCO
#
#
import sys
import json
from . import crypto
from . import db
from . import vtypes
class Election:
# Current state of an election.
S_EDITABLE = 'editable'
S_OPEN = 'open'
S_CLOSED = 'closed'
def __init__(self, db_fname):
self.db = db.DB(db_fname)
# Construct cursors for all operations.
self.c_salt_issue = self.db.add_statement(
'UPDATE ISSUES SET salt = ? WHERE _ROWID_ = ?')
self.c_salt_person = self.db.add_statement(
'UPDATE PERSON SET salt = ? WHERE _ROWID_ = ?')
self.c_open = self.db.add_statement(
'UPDATE METADATA SET salt = ?, opened_key = ?')
self.c_close = self.db.add_statement(
'UPDATE METADATA SET closed = 1')
self.c_add_issue = self.db.add_statement(
'''INSERT INTO ISSUES VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE SET
title=excluded.title,
description=excluded.description,
type=excluded.type,
kv=excluded.kv
''')
self.c_add_person = self.db.add_statement(
'''INSERT INTO PERSON VALUES (?, ?, ?, ?)
ON CONFLICT DO UPDATE SET
name=excluded.name,
email=excluded.email
''')
self.c_delete_issue = self.db.add_statement(
'DELETE FROM ISSUES WHERE iid = ?')
self.c_delete_person = self.db.add_statement(
'DELETE FROM PERSON WHERE pid = ?')
self.c_add_vote = self.db.add_statement(
'INSERT INTO VOTES VALUES (NULL, ?, ?, ?, ?)')
self.c_has_voted = self.db.add_statement(
'''SELECT 1 FROM VOTES
WHERE person_token = ? AND issue_token = ?
LIMIT 1
''')
# Cursors for running queries.
self.q_metadata = self.db.add_query('metadata',
'SELECT * FROM METADATA')
self.q_issues = self.db.add_query('issues',
'SELECT * FROM ISSUES ORDER BY iid')
self.q_person = self.db.add_query('person',
'SELECT * FROM PERSON ORDER BY pid')
self.q_get_issue = self.db.add_query('issues',
'SELECT * FROM ISSUES WHERE iid = ?')
self.q_get_person = self.db.add_query('person',
'SELECT * FROM PERSON WHERE pid = ?')
self.q_by_issue = self.db.add_query('votes',
'SELECT * FROM VOTES WHERE issue_token = ? ORDER BY _ROWID_')
def open(self):
# Double-check the Election is in the editing state.
assert self.is_editable()
# Add salts first. If this is gonna fail, then make sure it
# happens before we move to the "opened" state.
self.add_salts()
edata = self.gather_election_data()
print('EDATA:', edata)
salt = crypto.gen_salt()
opened_key = crypto.gen_opened_key(edata, salt)
print('SALT:', salt)
print('KEY:', opened_key)
self.c_open.perform((salt, opened_key))
def gather_election_data(self):
"Gather a definition of this election for keying and anti-tamper."
# NOTE: separators and other zero-entropy constant chars are
# not included when assembling the data for hashing. This data
# is not intended for human consumption, anyways.
# NOTE: all assembly of rows must use a repeatable ordering.
md = self.q_metadata.first_row()
mdata = md.eid + md.title
self.q_issues.perform()
# Use an f-string to render "None" if a column is NULL.
idata = ''.join(f'{i.iid}{i.title}{i.description}{i.type}{i.kv}'
for i in self.q_issues.fetchall())
self.q_person.perform()
pdata = ''.join(p.pid + p.email
for p in self.q_person.fetchall())
return (mdata + idata + pdata).encode()
def close(self):
"Close an election."
# The Election should be open.
assert self.is_open()
# Simple tweak of the metadata to close the Election.
self.c_close.perform()
def add_salts(self):
"Set the SALT column in the ISSUES and PERSON tables."
# The Election should be editable.
assert self.is_editable()
cur = self.db.conn.cursor()
def for_table(table, mod_cursor):
"Use MOD_CURSOR to salt each row of TABLE."
# Fetch all ROWID values now, to avoid two cursors
# attempting to work on TABLE at the same time.
cur.execute(f'SELECT _ROWID_ FROM {table}')
ids = cur.fetchall()
# Now, add a salt to every row.
for r in ids:
salt = crypto.gen_salt()
print('ROW will use:', table, r[0], salt)
mod_cursor.perform((salt, r[0]))
for_table('issues', self.c_salt_issue)
for_table('person', self.c_salt_person)
def get_metadata(self):
"Return basic metadata about this Election."
md = self.q_metadata.first_row()
# NOTE: do not return the SALT
# note: likely: never return opened_key
return md.eid, md.title, self.get_state()
def get_issue(self, iid):
"Return TITLE, DESCRIPTION, TYPE, and KV for issue IID."
# NEVER return issue.salt
issue = self.q_get_issue.first_row((iid,))
return (issue.title, issue.description, issue.type,
self.json2kv(issue.kv))
def add_issue(self, iid, title, description, vtype, kv):
"Add or update an issue designated by IID."
assert self.is_editable()
assert vtype in vtypes.TYPES
# If we ADD, then SALT will be NULL. If we UPDATE, then it will not
# be touched (it should be NULL).
self.c_add_issue.perform((iid, title, description, vtype,
self.kv2json(kv), None))
def delete_issue(self, iid):
"Delete the Issue designated by IID."
assert self.is_editable()
self.c_delete_issue.perform((iid,))
def list_issues(self):
"Return ordered (IID, TITLE, DESCRIPTION, TYPE, KV) for all ISSUES."
def extract_issue(row):
# NOTE: the SALT column is omitted. It should never be exposed.
return row[:4] + (self.json2kv(row.kv),)
self.q_issues.perform()
return [ extract_issue(row) for row in self.q_issues.fetchall() ]
def get_person(self, pid):
"Return NAME, EMAIL for Person identified by PID."
# NEVER return person.salt
person = self.q_get_person.first_row((pid,))
return person.name, person.email
def add_person(self, pid, name, email):
"Add or update a Person designated by PID."
assert self.is_editable()
# If we ADD, then SALT will be NULL. If we UPDATE, then it will not
# be touched (it should be NULL).
self.c_add_person.perform((pid, name, email, None))
def delete_person(self, pid):
"Delete the Person designated by PID."
assert self.is_editable()
self.c_delete_person.perform((pid,))
def list_persons(self):
"Return ordered (PID, NAME, EMAIL) for each Person."
# NOTE: the SALT column is omitted. It should never be exposed.
self.q_person.perform()
return [ row[:3] for row in self.q_person.fetchall() ]
def add_vote(self, pid, iid, votestring):
"Add VOTESTRING as the (latest) vote by PID for IID."
# The Election should be open.
assert self.is_open()
md = self.q_metadata.first_row()
person = self.q_get_person.first_row((pid,))
issue = self.q_get_issue.first_row((iid,))
### validate VOTESTRING for ISSUE.TYPE voting
person_token = crypto.gen_token(md.opened_key, pid, person.salt)
#print('PERSON:', pid, person.salt, person_token)
issue_token = crypto.gen_token(md.opened_key, iid, issue.salt)
#print('ISSUE:', iid, issue.salt, issue_token)
salt, token = crypto.create_vote(person_token, issue_token, votestring)
#print('SALT:', salt)
#print('TOKEN:', token)
self.c_add_vote.perform((person_token, issue_token, salt, token))
def tally_issue(self, iid):
"""Return the results for a given ISSUE-ID.
This is a 2-tuple: a human-readable string, and vtype-specific
supporting data.
Note: it is expected the caller has other details associated
with the issue, and knows the vote type and how to interpret
the supporting data.
"""
# The Election should be closed.
assert self.is_closed()
md = self.q_metadata.first_row()
issue = self.q_get_issue.first_row((iid,))
issue_token = crypto.gen_token(md.opened_key, iid, issue.salt)
# Use this dict to retain "most recent" votes.
dedup = { } # (PERSON_TOKEN, ISSUE_TOKEN) : VOTESTRING
self.q_by_issue.perform((issue_token,))
for row in self.q_by_issue.fetchall():
votestring = crypto.decrypt_votestring(
row.person_token, issue_token, row.salt, row.token)
dedup[row.person_token, row.issue_token] = votestring
# Make sure the votes are not in database-order.
# Note: we are not returning the votes, so this may be
# superfluous. But it certainly should not hurt.
votes = list(dedup.values())
crypto.shuffle(votes) # in-place
# Perform the tally, and return the results.
m = vtypes.vtype_module(issue.type)
return m.tally(votes, self.json2kv(issue.kv))
def has_voted_upon(self, pid):
"Return {ISSUE-ID: BOOL} stating what has been voted upon."
# The Election should be open.
assert self.is_open()
md = self.q_metadata.first_row()
person = self.q_get_person.first_row((pid,))
person_token = crypto.gen_token(md.opened_key, pid, person.salt)
voted_upon = { }
self.q_issues.perform()
for issue in self.q_issues.fetchall():
issue_token = crypto.gen_token(md.opened_key,
issue.iid,
issue.salt)
# Is any vote present?
self.c_has_voted.perform((person_token, issue_token))
row = self.c_has_voted.fetchone()
_ = self.c_has_voted.fetchall() # should be empty (LIMIT 1)
#print('HAS-VOTED:', row, '||', person_token, issue_token)
voted_upon[issue.iid] = row is not None
return voted_upon
def is_tampered(self):
# The Election should be open.
assert self.is_open()
md = self.q_metadata.first_row()
# Compute an opened_key based on the current data.
edata = self.gather_election_data()
opened_key = crypto.gen_opened_key(edata, md.salt)
print('EDATA:', edata)
print('SALT:', md.salt)
print('KEY:', opened_key)
# The computed key should be unchanged.
return opened_key != md.opened_key
def is_editable(self):
"Can this Election be edited?"
return self.get_state() == self.S_EDITABLE
def is_open(self):
"Is this Election open for voting?"
return self.get_state() == self.S_OPEN
def is_closed(self):
"Has this Election been closed?"
return self.get_state() == self.S_CLOSED
def get_state(self):
"Derive our election state from the METADATA table."
md = self.q_metadata.first_row()
if md.closed == 1:
assert md.salt is not None and md.opened_key is not None
return self.S_CLOSED
assert md.closed in (None, 0)
if md.salt is None:
assert md.opened_key is None
return self.S_EDITABLE
assert md.opened_key is not None
return self.S_OPEN
@staticmethod
def kv2json(kv):
'Convert a structured KV into a JSON string for storage.'
# Note: avoid serializing None.
return kv and json.dumps(kv)
@staticmethod
def json2kv(j):
'Convert the KV JSON string back into its structured value.'
return j and json.loads(j)
def new_eid():
"Create a new ElectionID."
# Use 4 bytes of a salt, for 32 bits.
b = crypto.gen_salt()
# Format into 8 hex characters.
return f'{b[0]:02x}{b[1]:02x}{b[2]:02x}{b[3]:02x}'