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
@@ -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()"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user