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:
Paul Hitt
2026-09-12 10:10:01 -04:00
co-authored by Claude Opus 5
parent 13f55076b9
commit 315e6d328f
10 changed files with 1085 additions and 3 deletions
@@ -7,6 +7,7 @@ import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.E2ESession
import io.flamenet.e2e.Envelope
import io.flamenet.e2e.PeerBundle
import io.flamenet.e2e.PollBook
import io.flamenet.e2e.SealedSender
import io.flamenet.e2e.Vault
@@ -137,6 +138,78 @@ class Conversations(
*/
var requireSealed: () -> Boolean = { false }
// ---- Polls (§6.29) ----------------------------------------------------.
/** Polls in flight, restored from the vault the first time they are asked for. */
val polls: PollBook by lazy {
PollBook().apply { vault.loadPolls()?.let { restore(it) } }
}
private fun savePolls() = vault.savePolls(polls.archive())
/**
* Ask a poll of a group. Nothing is voted here: the poll goes to the group
* and every member, this one included, votes separately.
*/
fun askPoll(
group: RelayClient.Group,
myUserId: Long,
question: String,
options: List<String>,
): String {
val members = (group.memberIds + myUserId).distinct().sorted()
require(members.size >= 2) { "a poll needs somebody else in the group" }
val id = java.util.UUID.randomUUID().toString()
val set = PollBook.memberSet(members)
val body = Content.poll(id, question, options, group.id, set)
polls.remember(id, group.id, question, options, set)
savePolls()
for (member in members) {
if (member == myUserId) continue
runCatching { sendPlaintext(member, body) }
}
return id
}
/**
* Vote, which means handing every member one piece of the vote and keeping
* one. No member is ever sent the vote itself.
*/
fun vote(
pollId: String,
myUserId: Long,
choice: Int,
) {
val entry = polls.poll(pollId) ?: return
val outgoing = polls.vote(pollId, myUserId, choice)
savePolls()
for ((member, piece) in outgoing) {
// One envelope per member, each different. This is the one place a
// group message is not the same message to everybody: sending the
// same piece to all of them would let any member subtract it from
// the tally and read the vote.
runCatching { sendPlaintext(member, Content.pollShare(pollId, piece, entry.groupId)) }
}
publishPollSum(pollId)
}
/**
* Publish this device's sum, once every member has voted and never before:
* a sum published early leaves noise in the total rather than a smaller
* count.
*/
private fun publishPollSum(pollId: String) {
val me = myUserId ?: return
val entry = polls.poll(pollId) ?: return
val sum = polls.sumToPublish(pollId, me) ?: return
savePolls()
val body = Content.pollSum(pollId, sum, entry.groupId)
for (member in entry.members) {
if (member == me) continue
runCatching { sendPlaintext(member, body) }
}
}
fun sendToGroup(
group: RelayClient.Group,
text: String,
@@ -1469,6 +1542,37 @@ class Conversations(
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
}
Content.Kind.POLL -> {
val raw = content.raw ?: return
val opts = raw.optJSONArray("opts") ?: return
val options = (0 until opts.length()).map { opts.optString(it) }
if (polls.remember(
raw.optString("pid"),
raw.optString("gid"),
raw.optString("q"),
options,
raw.optString("mset"),
)
) {
savePolls()
}
}
Content.Kind.POLL_SHARE -> {
val raw = content.raw ?: return
val piece = Content.pollVector(content) ?: return
if (polls.receivePiece(raw.optString("pid"), fromUser, piece)) {
savePolls()
publishPollSum(raw.optString("pid"))
}
}
Content.Kind.POLL_SUM -> {
val raw = content.raw ?: return
val sum = Content.pollVector(content) ?: return
if (polls.receiveSum(raw.optString("pid"), fromUser, sum)) savePolls()
}
Content.Kind.OFF_RECORD -> {
val on = Content.offTheRecord(content) ?: return
vault.saveOffTheRecord(Vault.threadForUser(fromUser), on)
@@ -0,0 +1,85 @@
package io.flamenet.client
import java.io.File
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A share goes to one member. Sending the same one to the group reveals the
* vote (§6.29).
*
* Every other group envelope in the app is the same bytes to everybody, so the
* group send is the obvious thing to reach for, and it is the one call that
* must not appear in the vote path: handing every member the same piece means
* any of them can subtract it from the tally, and the poll is anonymous in name
* only. The mistake leaves no trace at runtime. The counts still come out
* right.
*
* `Conversations` needs a relay, so this reads the source, bounded to the
* function each check is about.
*/
class PollSharesGoToOneMemberTest {
private val source =
File("src/main/kotlin/io/flamenet/client/Conversations.kt").readText()
/** From a declaration to the next one at the same indent. */
private fun function(marker: String): String {
val start = source.indexOf(marker)
assertTrue(start >= 0, "missing $marker")
val rest = source.substring(start + marker.length)
val end =
listOf("\n fun ", "\n private fun ", "\n // ----")
.mapNotNull { rest.indexOf(it).takeIf { i -> i >= 0 } }
.minOrNull() ?: rest.length
return rest.substring(0, end)
}
@Test
fun `voting sends each piece to the member it belongs to`() {
val vote = function("fun vote(")
assertTrue(
vote.contains("for ((member, piece) in outgoing)"),
"each piece must be addressed to its own member",
)
assertTrue(vote.contains("sendPlaintext(member, Content.pollShare("))
}
@Test
fun `and never sends one to the whole group`() {
val vote = function("fun vote(")
assertFalse(
vote.contains("sendToGroup"),
"sending a share to the group hands every member the same piece, and any one " +
"of them can then subtract it from the tally to read the vote",
)
}
/**
* The published sum is the same to everybody, which is the other half of
* the rule: if it ever became a per-member calculation it would mean
* somebody had copied the share path without knowing why it differed.
*/
@Test
fun `the sum is the same envelope to every member`() {
val publish = function("private fun publishPollSum(")
assertTrue(publish.contains("val body = Content.pollSum("))
assertTrue(publish.contains("for (member in entry.members)"))
}
/**
* A sum published before the last vote arrives is noise in the total, not
* a smaller count, so the decision stays with the book.
*/
@Test
fun `publishing waits for the book to say so`() {
assertTrue(function("private fun publishPollSum(").contains("polls.sumToPublish("))
}
/** And a poll that is not written down could never finish. */
@Test
fun `every change to a poll is written down`() {
assertTrue(function("fun askPoll(").contains("savePolls()"))
assertTrue(function("fun vote(").contains("savePolls()"))
}
}