live · mainnetoc · docs
specs · api · guides
docs / secret ballot

secret ballot

In secret mode, each ballot is an OC Lock envelope addressed to a poll-specific X25519 reveal keypair. At deadline the creator publishes reveal_sk. Anyone can then decrypt all ballots and tally.

how it works

  1. Poll creation. Creator generates a fresh X25519 keypair. reveal_pk goes into the poll; reveal_sk stays local (IndexedDB).

  2. Ballot. Voter encrypts the chosen option id with OC Lock v2 envelope format to a synthetic recipient whose device_pk = reveal_pk. ballot.option is set to null; the real choice is inside ballot.secret.envelope. The ballot.secret.commit binds the ballot id to the hashed plaintext:

    commit = sha256("oc-vote/v0/commit\npoll_id: …\nvoter: …\noption: …\n")
    
  3. Cast. Voter signs the ballot with BIP-322 and publishes to Nostr kind 30081. Observers see who voted. Nobody (except the creator, who holds reveal_sk) sees what.

  4. Deadline. Creator publishes a kind-30082 reveal event: { poll_id, reveal_sk, revealed_at, sig }.

  5. Tally. Every observer verifies the reveal's BIP-322 signature, unseals each ballot, verifies each commit, and computes the tally. Any commit mismatch drops the ballot (E_COMMIT_MISMATCH).

honest trade-offs

The creator holds reveal_sk in v0. This is named and documented, not hidden.

Early peeking. The creator can decrypt ballots before deadline with their local key. They cannot publish a tally without revealing the key, which is an observable action. A malicious creator could privately know the running score and strategize off of it. Full coercion-resistance requires threshold reveal (future work) or homomorphic tally (Helios-style, out of scope).

Non-reveal. If the creator disappears or refuses to publish the reveal event, the poll is permanently abandoned. Observers can enumerate the sealed ballots — proving participation — but cannot produce a tally. This is a real cost. Communities that can't tolerate it should use public polls or wait for threshold-reveal.

Receipt-freeness. After reveal, every voter's choice is publicly linkable to their Bitcoin address. A coercer can demand proof of vote. This is a failure of receipt-freeness that v0 does not attempt to fix.

future: threshold reveal

v1 will offer two alternatives to creator-held reveal:

  • n-of-m trustees. reveal_sk is secret-shared across m parties; any n can reconstruct. Names each trustee publicly.
  • drand tlock. reveal_sk is locked to a future drand beacon round. No party holds it until the round fires.

Both eliminate the "single creator can peek early" and "single creator can refuse to reveal" failure modes. Both add coordination overhead and are why v0 ships the simpler version first.

status in this client

Secret mode is fully wired end-to-end as of this release:

  • /create — pick ballot mode: secret to generate a local X25519 reveal keypair. reveal_pk is embedded in the poll; reveal_sk is saved to this browser's IndexedDB.
  • /p/<poll_id> — for secret-mode polls, the ballot form seals your chosen option id into an OC Lock v2 envelope addressed to reveal_pk and signs the outer ballot with BIP-322. Live tally shows awaiting reveal until the creator publishes.
  • /reveal/<poll_id> — the creator's ceremony page. After deadline, paste (or load from local storage) reveal_sk, sign a BIP-322 over the reveal object's id, publish kind 30082.

Envelope composition uses @orangecheck/lock-core — the same primitive that powers lock.ochk.io. OC Vote doesn't ship its own crypto; it composes.