use std::collections::HashMap;
use serde_json::Value;
use sha2::{Digest, Sha256};
use trust_tasks_rs::specs::voting::{
ballot::cast::v0_1 as cast,
election::register::v0_1::{Manifest, Revote},
};
use crate::{
ballot_box::{BallotBox, MemoryNullifiers, Revote as BoxRevote},
board::{Board, Hash},
election::{Election, ElectionId},
rules::{self, digest_from_multibase},
scheme::VotingParams,
steps::ProvenRequests,
tally,
voter::Cast,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AuditFailure {
#[error("the entries do not hash to the head's root")]
RootMismatch,
#[error("the board is not consistent with the checkpoint at tree size {tree_size}")]
CheckpointInconsistent { tree_size: u64 },
#[error("the cast at index {index} would not have been admitted: {reason}")]
CastInvalid { index: u64, reason: String },
#[error("the tag at index {index} is already on the board")]
TagRepeated { index: u64 },
#[error("the replacement at index {index} is not valid: {reason}")]
ReplacementInvalid { index: u64, reason: String },
#[error("the published result is not the board's: {0}")]
ResultMismatch(String),
#[error("{counted} ballots counted, but only {issued} tickets were issued")]
CountExceedsTickets { counted: u64, issued: u64 },
#[error("{0}")]
Unreadable(String),
#[error("the trustee entry at index {index} does not hold: {reason}")]
CeremonyInvalid { index: u64, reason: String },
#[error("the entry at index {index} is out of the election's order: {reason}")]
OrderInvalid { index: u64, reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HeadRef {
pub tree_size: u64,
pub root: Hash,
}
impl HeadRef {
pub fn from_wire(head: &Value) -> Result<Self, AuditFailure> {
let tree_size = head["treeSize"]
.as_u64()
.ok_or_else(|| AuditFailure::Unreadable("a head without treeSize".into()))?;
let root = head["rootHash"]
.as_str()
.and_then(digest_from_multibase)
.ok_or_else(|| AuditFailure::Unreadable("a head without a rootHash".into()))?;
Ok(Self { tree_size, root })
}
}
#[derive(Debug, Clone, Copy)]
pub struct Evidence<'a> {
pub manifest: &'a Value,
pub params: &'a VotingParams,
pub entries: &'a [Value],
pub head: HeadRef,
pub checkpoints: &'a [HeadRef],
pub result: &'a Value,
pub tickets_issued: u64,
pub proven: &'a ProvenRequests,
}
#[derive(Debug, Clone)]
pub struct Verified {
pub ballots_counted: u64,
pub questions: Value,
}
#[path = "audit_encrypted.rs"]
mod encrypted;
fn canonical(v: &Value) -> Result<Vec<u8>, AuditFailure> {
serde_json_canonicalizer::to_vec(v)
.map_err(|e| AuditFailure::Unreadable(format!("canonicalize: {e}")))
}
pub fn audit(e: Evidence<'_>) -> Result<Verified, AuditFailure> {
let manifest: Manifest = serde_json::from_value(e.manifest.clone())
.map_err(|err| AuditFailure::Unreadable(format!("manifest: {err}")))?;
let digest = rules::manifest_digest(e.manifest)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let election = Election::new(
e.params.community(),
ElectionId::new(manifest.election_id.as_str())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?,
digest,
)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let mut board = Board::new();
let mut leaves_bytes = Vec::with_capacity(e.entries.len());
for entry in e.entries {
let bytes = canonical(entry)?;
board.append(&bytes);
leaves_bytes.push(bytes);
}
if board.len() as u64 != e.head.tree_size || board.root(board.len()).ok() != Some(e.head.root) {
return Err(AuditFailure::RootMismatch);
}
for c in e.checkpoints {
let size = usize::try_from(c.tree_size).unwrap_or(usize::MAX);
if board.root(size).ok() != Some(c.root) {
return Err(AuditFailure::CheckpointInconsistent {
tree_size: c.tree_size,
});
}
}
let result_index = check_order(&manifest, e.entries)?;
let result_entry = Some(&e.entries[result_index]);
let revote = if manifest.revote == Revote::Replace {
BoxRevote::Replace
} else {
BoxRevote::Refuse
};
if manifest.trustees.is_some() {
let ctx = election.trustee_context();
let verifier = BallotBox::new(e.params, election, revote, MemoryNullifiers::default())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let (n, questions) = encrypted::recount(
&manifest,
&digest,
encrypted::Steps {
community: e.params.community(),
ctx,
proven: e.proven,
},
&verifier,
revote,
e.entries,
&board,
)?;
return finish(e, &board, &digest, n, questions, result_entry, result_index);
}
let verifier = BallotBox::new(e.params, election, revote, MemoryNullifiers::default())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let mut counted: HashMap<String, (u64, cast::Ballot)> = HashMap::new();
let mut owed: Option<(String, u64, u64)> = None;
for (i, entry) in e.entries.iter().enumerate() {
let index = i as u64;
match entry["kind"].as_str() {
Some("cast") => {
if let Some((_, _, by)) = owed {
return Err(AuditFailure::ReplacementInvalid {
index: by,
reason:
"a later cast under a counted tag is not followed by its replacement"
.into(),
});
}
let invalid = |reason: String| AuditFailure::CastInvalid { index, reason };
let wire: cast::Cast = serde_json::from_value(entry["cast"].clone())
.map_err(|err| invalid(format!("unreadable: {err}")))?;
rules::check_ballot(&manifest, &digest, &wire.ballot)
.map_err(|err| invalid(err.to_string()))?;
let attestation = vti_vetting_pcs::scheme::dec(wire.attestation.as_str())
.map_err(|err| invalid(format!("attestation: {err}")))?;
let ballot = canonical(&entry["cast"]["ballot"])?;
let tag = verifier
.verify(&Cast { attestation }, &ballot)
.map_err(|err| invalid(err.to_string()))?;
if tag != wire.tag.as_str() {
return Err(invalid("the tag is not the attestation's".into()));
}
match counted.get(&tag) {
None => {
counted.insert(tag, (index, wire.ballot));
}
Some(_) if revote == BoxRevote::Refuse => {
return Err(AuditFailure::TagRepeated { index });
}
Some((earlier, held)) => {
if canonical(&serde_json::to_value(held).unwrap_or_default())?
== canonical(&entry["cast"]["ballot"])?
{
return Err(AuditFailure::ReplacementInvalid {
index,
reason: "a replacement must carry another ballot".into(),
});
}
owed = Some((tag.clone(), *earlier, index));
counted.insert(tag, (index, wire.ballot));
}
}
}
Some("replaced") => {
let bad = |reason: &str| AuditFailure::ReplacementInvalid {
index,
reason: reason.into(),
};
let Some((tag, earlier, by)) = owed.take() else {
return Err(bad("no cast is waiting to be replaced"));
};
if entry["tag"].as_str() != Some(tag.as_str())
|| entry["replaces"].as_u64() != Some(earlier)
|| entry["by"].as_u64() != Some(by)
{
return Err(bad("it names other casts than the two under its tag"));
}
}
_ => {}
}
}
if let Some((_, _, by)) = owed {
return Err(AuditFailure::ReplacementInvalid {
index: by,
reason: "a later cast under a counted tag is not followed by its replacement".into(),
});
}
let ballots: Vec<&cast::Ballot> = counted.values().map(|(_, b)| b).collect();
let (n, questions) = tally::tally(&manifest, ballots, false)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let questions = serde_json::to_value(&questions)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
finish(e, &board, &digest, n, questions, result_entry, result_index)
}
fn check_order(manifest: &Manifest, entries: &[Value]) -> Result<usize, AuditFailure> {
let at = |e: &Value| {
e["at"]
.as_str()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| t.with_timezone(&chrono::Utc))
};
let mut opened = false;
let mut key = false;
let mut closing = false;
let mut authorized = false;
let mut result: Option<usize> = None;
for (i, e) in entries.iter().enumerate() {
let bad = |reason: &str| AuditFailure::OrderInvalid {
index: i as u64,
reason: reason.into(),
};
if result.is_some() {
return Err(bad("an entry after the result"));
}
match e["kind"].as_str() {
Some(crate::steps::KIND) if key => {
return Err(bad("a trustee step after the election key"));
}
Some("electionKey") => {
if key || opened {
return Err(bad("the election key after voting opened, or twice"));
}
key = true;
}
Some("opened") => {
if opened || closing {
return Err(bad("voting opened twice, or after it ended"));
}
if at(e).is_none_or(|t| t < manifest.voting_opens) {
return Err(bad("voting opened before votingOpens"));
}
opened = true;
}
Some("cast" | "encryptedCast") => {
if !opened || closing {
return Err(bad("a cast outside the voting window"));
}
}
Some(kind @ ("closed" | "closedEarly" | "cancelled")) => {
if closing {
return Err(bad("the election ended twice"));
}
if kind == "closedEarly" && !manifest.allow_early_close {
return Err(bad("closed early where the manifest allows no early close"));
}
if kind == "closed" && at(e).is_none_or(|t| t < manifest.voting_closes) {
return Err(bad("closed before votingCloses"));
}
if kind != "cancelled" && !opened {
return Err(bad("closed before it opened"));
}
closing = true;
}
Some("decryptionAuthorized") => {
if !closing || authorized {
return Err(bad("a decryption authorized before the close, or twice"));
}
authorized = true;
}
Some("partials" | "unsealed" | "spoiled") => {
if !authorized {
return Err(bad("a decryption the community did not authorize"));
}
}
Some("result") => {
if !closing {
return Err(bad("a result before the close"));
}
result = Some(i);
}
_ => {}
}
}
result.ok_or_else(|| AuditFailure::ResultMismatch("the board records no result".into()))
}
pub fn decryption(
manifest_value: &Value,
params: &VotingParams,
entries: &[Value],
proven: &ProvenRequests,
) -> Result<Option<encrypted::Closing>, AuditFailure> {
let manifest: Manifest = serde_json::from_value(manifest_value.clone())
.map_err(|err| AuditFailure::Unreadable(format!("manifest: {err}")))?;
let digest = rules::manifest_digest(manifest_value)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let election = Election::new(
params.community(),
ElectionId::new(manifest.election_id.as_str())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?,
digest,
)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let revote = if manifest.revote == Revote::Replace {
BoxRevote::Replace
} else {
BoxRevote::Refuse
};
let mut board = Board::new();
for entry in entries {
board.append(&canonical(entry)?);
}
let ctx = election.trustee_context();
let verifier = BallotBox::new(params, election, revote, MemoryNullifiers::default())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
encrypted::closing(
&manifest,
&digest,
encrypted::Steps {
community: params.community(),
ctx,
proven,
},
&verifier,
revote,
entries,
&board,
)
}
pub use encrypted::{Closing, targets_digest};
pub fn election_key(
manifest: &Value,
community: &str,
entries: &[Value],
proven: &ProvenRequests,
) -> Result<Option<Value>, AuditFailure> {
let typed: Manifest = serde_json::from_value(manifest.clone())
.map_err(|err| AuditFailure::Unreadable(format!("manifest: {err}")))?;
let digest = rules::manifest_digest(manifest)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?;
let ctx = Election::new(
community,
ElectionId::new(typed.election_id.as_str())
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?,
digest,
)
.map_err(|err| AuditFailure::Unreadable(err.to_string()))?
.trustee_context();
encrypted::formed_key(
&typed,
encrypted::Steps {
community,
ctx,
proven,
},
entries,
)
}
fn finish(
e: Evidence<'_>,
board: &Board,
digest: &[u8; 32],
n: u64,
questions: Value,
result_entry: Option<&Value>,
result_index: usize,
) -> Result<Verified, AuditFailure> {
let digest = *digest;
let published = e.result;
if published["ballotsCounted"].as_u64() != Some(n) {
return Err(AuditFailure::ResultMismatch(format!(
"it counts {}, the board {n}",
published["ballotsCounted"]
)));
}
if !same_questions(&published["questions"], &questions) {
return Err(AuditFailure::ResultMismatch(
"the totals differ from the counted ballots'".into(),
));
}
if published["manifestDigest"]
.as_str()
.and_then(digest_from_multibase)
!= Some(digest)
{
return Err(AuditFailure::ResultMismatch(
"it names another manifest".into(),
));
}
let at = HeadRef::from_wire(&published["head"])?;
if at.tree_size != result_index as u64 || board.root(result_index).ok() != Some(at.root) {
return Err(AuditFailure::ResultMismatch(
"its head is not the board the result was published on".into(),
));
}
if let Some(entry) = result_entry
&& !same_result(&entry["result"], published)
{
return Err(AuditFailure::ResultMismatch(
"it is not the result the board records".into(),
));
}
if n > e.tickets_issued {
return Err(AuditFailure::CountExceedsTickets {
counted: n,
issued: e.tickets_issued,
});
}
Ok(Verified {
ballots_counted: n,
questions,
})
}
fn same_questions(a: &Value, b: &Value) -> bool {
let index = |v: &Value| -> Option<HashMap<String, Value>> {
v.as_array()?
.iter()
.map(|q| Some((q["question"].as_str()?.to_string(), q.clone())))
.collect()
};
let rows = |v: &Value| v.as_array().map_or(0, Vec::len);
if rows(a) != rows(b) {
return false;
}
match (index(a), index(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
fn same_result(a: &Value, b: &Value) -> bool {
let strip = |v: &Value| {
let mut v = v.clone();
if let Some(h) = v.get_mut("head").and_then(Value::as_object_mut) {
h.remove("issuedAt");
}
v
};
strip(a) == strip(b)
}
pub fn result_digest(result: &Value) -> Result<String, AuditFailure> {
Ok(rules::digest_multibase(
&Sha256::digest(canonical(result)?).into(),
))
}