Polls where nobody can tell who voted for what
The secure-aggregation core has been in the engine since it was written and nothing used it: there was no way to ask a poll, vote in one, or see a result. This is the rest. `PollBook` is the sequencing, deliberately the same shape as the iOS one. A poll crosses between the two engines, so a rule enforced on one side and not the other is a poll that tallies differently depending on who is looking at it. No sum is published until every member has voted: a member who has not voted has handed nobody a piece, so nobody holds a complete set, and a sum published early is noise rather than a partial answer. Asking a poll sends the same envelope to the group. Voting does not: each piece is addressed to the member it belongs to and sent to them alone. That is the one group envelope in the app which is not the same bytes to everybody, and a test pins it, because sending one share to the group would let any member subtract it from the tally and read the vote, with the counts still coming out right and nothing at runtime to notice. Kept in the vault, or a poll could never finish: it waits for everybody, and the pieces gathered would be gone the first time the app closed. The card says what the maths actually gives. Everyone else would have to work together to know how one person voted, never that nobody can know. A poll of two is not anonymous at all, because the other person holds a piece of the vote and sees the tally, so the word is not offered for a pair and the card says why. 22 new tests. 647 in the engine and 430 in the client, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
13f55076b9
commit
315e6d328f
@@ -0,0 +1,216 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.util.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Running a whole poll between three people, without a relay (§6.29).
|
||||
*
|
||||
* The sequencing is the part that goes wrong: a sum published before everybody
|
||||
* has voted is not a partial answer, it is noise, and a piece counted twice is
|
||||
* a wrong count that nothing else would catch.
|
||||
*/
|
||||
class PollBookTest {
|
||||
private val members = listOf(4L, 7L, 9L)
|
||||
|
||||
private fun book(): PollBook =
|
||||
PollBook().apply {
|
||||
remember("p1", "g1", "Lunch?", listOf("Thai", "Pizza"), PollBook.memberSet(members))
|
||||
}
|
||||
|
||||
private fun rng(seed: Long) = Random(seed)
|
||||
|
||||
/** Everybody votes, the pieces travel, everybody publishes. */
|
||||
@Test
|
||||
fun `a whole poll between three people`() {
|
||||
val books = members.associateWith { book() }
|
||||
val choices = mapOf(4L to 1, 7L to 0, 9L to 1)
|
||||
|
||||
for (voter in members) {
|
||||
val outgoing = books[voter]!!.vote("p1", voter, choices[voter]!!, rng(voter))
|
||||
assertEquals(members.size - 1, outgoing.size, "my own piece never leaves")
|
||||
for ((member, piece) in outgoing) {
|
||||
assertTrue(books[member]!!.receivePiece("p1", voter, piece))
|
||||
}
|
||||
}
|
||||
|
||||
val sums = members.associateWith { assertNotNull(books[it]!!.sumToPublish("p1", it)) }
|
||||
for ((publisher, sum) in sums) {
|
||||
for (member in members) {
|
||||
if (member != publisher) {
|
||||
assertTrue(books[member]!!.receiveSum("p1", publisher, sum))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (member in members) {
|
||||
val standing = books[member]!!.poll("p1")!!.standing
|
||||
assertTrue(standing is PollBook.Standing.Counted, "$member has no tally")
|
||||
assertContentEquals(
|
||||
intArrayOf(1, 2),
|
||||
(standing as PollBook.Standing.Counted).counts,
|
||||
"one Thai, two Pizza",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The rule the interface depends on. */
|
||||
@Test
|
||||
fun `no sum is published before everybody has voted`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
assertNull(mine.sumToPublish("p1", 4L), "only my own vote is in")
|
||||
|
||||
val other = book()
|
||||
val from7 = other.vote("p1", 7L, 1, rng(2))
|
||||
mine.receivePiece("p1", 7L, from7.first { it.first == 4L }.second)
|
||||
assertNull(mine.sumToPublish("p1", 4L), "still nothing from 9")
|
||||
|
||||
val third = book()
|
||||
val from9 = third.vote("p1", 9L, 1, rng(3))
|
||||
mine.receivePiece("p1", 9L, from9.first { it.first == 4L }.second)
|
||||
assertNotNull(mine.sumToPublish("p1", 4L), "now every member has voted")
|
||||
}
|
||||
|
||||
/** And it says who it is waiting on. */
|
||||
@Test
|
||||
fun `it says who it is waiting for`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
val standing = mine.poll("p1")!!.standing
|
||||
assertTrue(standing is PollBook.Standing.WaitingForVotes)
|
||||
assertEquals(listOf(7L, 9L), (standing as PollBook.Standing.WaitingForVotes).from)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sum is published only once`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
for (voter in listOf(7L, 9L)) {
|
||||
val out = book().vote("p1", voter, 1, rng(voter))
|
||||
mine.receivePiece("p1", voter, out.first { it.first == 4L }.second)
|
||||
}
|
||||
assertNotNull(mine.sumToPublish("p1", 4L))
|
||||
assertNull(mine.sumToPublish("p1", 4L), "a second publish would double the count")
|
||||
}
|
||||
|
||||
/** Redelivery is the failure that shows up as a tally quietly being wrong. */
|
||||
@Test
|
||||
fun `a redelivered piece is ignored`() {
|
||||
val out = book().vote("p1", 7L, 1, rng(2))
|
||||
val piece = out.first { it.first == 4L }.second
|
||||
val mine = book()
|
||||
assertTrue(mine.receivePiece("p1", 7L, piece))
|
||||
assertFalse(mine.receivePiece("p1", 7L, piece), "counted twice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redelivered sum is ignored`() {
|
||||
val mine = book()
|
||||
assertTrue(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
|
||||
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stranger cannot join the arithmetic`() {
|
||||
val mine = book()
|
||||
assertFalse(mine.receivePiece("p1", 999L, intArrayOf(1, 0)))
|
||||
assertFalse(mine.receiveSum("p1", 999L, intArrayOf(1, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vector of the wrong width is refused`() {
|
||||
val mine = book()
|
||||
assertFalse(mine.receivePiece("p1", 7L, intArrayOf(1, 0, 0)))
|
||||
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(1)))
|
||||
}
|
||||
|
||||
/** Both engines index the pieces the same way, whatever order was learned. */
|
||||
@Test
|
||||
fun `the member set is order independent`() {
|
||||
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 4L, 7L)))
|
||||
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 9L, 4L, 7L)))
|
||||
assertEquals(listOf(4L, 7L, 9L), PollBook.members("4,7,9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redelivered poll does not reset it`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
assertFalse(
|
||||
mine.remember(
|
||||
"p1",
|
||||
"g1",
|
||||
"Lunch?",
|
||||
listOf("Thai", "Pizza"),
|
||||
PollBook.memberSet(members),
|
||||
),
|
||||
)
|
||||
assertEquals(0, mine.poll("p1")!!.myChoice, "the vote survived")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voting twice changes nothing`() {
|
||||
val mine = book()
|
||||
val first = mine.vote("p1", 4L, 0, rng(1))
|
||||
val second = mine.vote("p1", 4L, 1, rng(1))
|
||||
assertTrue(first.isNotEmpty())
|
||||
assertTrue(second.isEmpty(), "a second vote must send nothing")
|
||||
assertEquals(0, mine.poll("p1")!!.myChoice)
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll has to survive being closed, or it could never finish: it waits
|
||||
* for every member, and the pieces already gathered would be gone.
|
||||
*/
|
||||
@Test
|
||||
fun `it survives a round trip through storage`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 1, rng(1))
|
||||
val out = book().vote("p1", 7L, 0, rng(2))
|
||||
mine.receivePiece("p1", 7L, out.first { it.first == 4L }.second)
|
||||
|
||||
val back = PollBook()
|
||||
back.restore(JSONObject(mine.archive().toString()))
|
||||
|
||||
val e = assertNotNull(back.poll("p1"))
|
||||
assertEquals("Lunch?", e.question)
|
||||
assertEquals(listOf("Thai", "Pizza"), e.options)
|
||||
assertEquals(members, e.members)
|
||||
assertEquals(1, e.myChoice)
|
||||
assertContentEquals(
|
||||
mine.poll("p1")!!.piecesHeld[7L],
|
||||
e.piecesHeld[7L],
|
||||
"the piece somebody handed us has to come back exactly",
|
||||
)
|
||||
assertContentEquals(mine.poll("p1")!!.piecesHeld[4L], e.piecesHeld[4L])
|
||||
}
|
||||
|
||||
/** And the negative values in a share have to survive it too. */
|
||||
@Test
|
||||
fun `storage keeps the sign and the extremes`() {
|
||||
val mine = book()
|
||||
mine.receiveSum("p1", 7L, intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE))
|
||||
val back = PollBook()
|
||||
back.restore(JSONObject(mine.archive().toString()))
|
||||
assertContentEquals(
|
||||
intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE),
|
||||
back.poll("p1")!!.sumsPublished[7L],
|
||||
)
|
||||
}
|
||||
|
||||
/** One member is not a poll. */
|
||||
@Test
|
||||
fun `a poll needs at least two members`() {
|
||||
val b = PollBook()
|
||||
assertTrue(b.remember("p2", "g1", "?", listOf("a", "b"), "4,7"))
|
||||
assertFalse(b.remember("p3", "g1", "?", listOf("a", "b"), "4"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user