Skip to content
commit-reveal Source  →

← Back to notes

Implementing a sealed-bid auction with commit-reveal

commit-reveal maintainers · ·
use-caseauctions

Sealed-bid auctions are the canonical use case for commit-reveal. The protocol is easy to explain (bidders commit, then reveal), and the failure modes (peeking, late bids, non-reveals) are explicit. This post walks through a working implementation with commit-reveal, pointing out the places where the cryptographic primitive ends and the protocol design begins.

The implementation here is a simplified version of the auction pattern shown in the library’s docs/use-cases.md, restructured to make the protocol design choices explicit. It is not a production auction system; it is enough to show what the primitive does and does not give you.

The protocol

A sealed-bid auction in two phases:

  1. Commit phase. Each bidder publishes H(bid || salt) to the auctioneer (or to a public log). Nobody, including the auctioneer, can read the bid. The bidder keeps the bid and salt locally.

  2. Reveal phase. Each bidder publishes (bid, salt). The auctioneer recomputes H(bid || salt) and checks it against the commitment. If it matches, the bid is valid; if it doesn’t, or if the bidder refuses to reveal, the bid is discarded or the bidder is penalised (depending on protocol design).

After all reveals are in, the auctioneer ranks the valid bids and announces a winner. Because nobody could see anyone else’s bid during the commit phase, late commitments cannot be tactically adjusted.

A bidder, with the library

A bidder needs to do two things: produce a commitment, and later produce a reveal. With CommitRevealScheme, that is two method calls.

from commit_reveal import CommitRevealScheme

scheme = CommitRevealScheme(hash_algorithm="sha3_256")

# Phase 1: commit
bid_amount = 1750  # in whatever unit
commitment, salt = scheme.commit(bid_amount)
publish_to_auctioneer(commitment)
store_locally({"bid": bid_amount, "salt": salt})

# ... time passes; commit deadline closes; reveal window opens ...

# Phase 2: reveal
publish_to_auctioneer({"bid": bid_amount, "salt": salt})

The auctioneer does the corresponding verification:

def verify_bid(bidder_id, bid_amount, salt):
    commitment = stored_commitments[bidder_id]
    return scheme.reveal(bid_amount, salt, commitment)

That is the cryptographic core. Two method calls per bidder, one on each side. Everything else — deadlines, messaging, ranking — is application code that has nothing to do with cryptography.

Why the salt matters

A common newcomer question: why include a salt? Why not just H(bid)?

Because bid spaces are small. If bids are integers in dollars between $0 and $10,000, an attacker who sees H(bid) can simply brute-force the 10,001 possible inputs and learn the bid from the commitment immediately. The commitment is no longer hiding.

The salt is what makes the commitment hiding for small input spaces. A 32-byte random salt (the default in this library, generated via secrets.token_bytes(32)) gives the commitment 256 bits of entropy independent of how predictable the bid is. The attacker would have to brute-force all 2**256 possible salts per candidate bid, which is computationally infeasible.

This is the kind of subtlety that the library handles for you by default. If you don’t pass a salt to commit(), it generates a cryptographically secure one. If you do pass one, the library validates it.

Adding a ZKP

In a basic sealed-bid auction the reveal is the proof: bidder publishes (bid, salt), auctioneer hashes them, checks. But sometimes you want a third party to verify the bid without learning what it was — for instance, an arbitration log that records “bidder X had a valid commitment” without recording the bid.

That is what the Schnorr ZKP path is for.

scheme = CommitRevealScheme(hash_algorithm="sha3_256", use_zkp=True)

commitment, salt = scheme.commit(bid_amount)
public_key, R_compressed, challenge, response = scheme.create_zkp_proof(
    bid_amount, salt, commitment
)

# publish (commitment, public_key, R_compressed, challenge, response)
# anyone can verify "bidder knew the value that produced this commitment"
# without learning the value
assert scheme.verify_zkp_proof(
    commitment, public_key, R_compressed, challenge, response
)

The proof is non-interactive (one message, no back-and-forth), made non-interactive via the Fiat-Shamir transformation with SHA-256 as the challenge hash. The underlying curve is secp256k1, the same curve as Bitcoin.

SECURITY.md flags one thing about ZKP usage worth re-reading: do not re-use a ZKP public key across different commitments. The library derives the proof secret deterministically from (value, salt), which means each commitment has its own derived key. As long as you do not artificially share salts between bids, you are in the safe regime.

What the library does not give you

The cryptographic part is not the hard part of an auction system. The hard parts are:

  1. What if a bidder refuses to reveal? They committed to a bid; they have not revealed it; the auction would benefit from their bid being discarded (if it would have lost) or included (if it would have won). The cryptographic primitive does not solve this. Your protocol has to.

    Common designs: a deposit posted at commit time that is forfeited on non-reveal; a default reveal value (e.g., the reserve price) applied to non-revealers; a separate dispute window where third parties can submit a candidate reveal that the bidder has to refute.

  2. What if a bidder reveals a bid below the reserve price? The library will verify the reveal is valid; whether to discard it is policy.

  3. What if the auctioneer themselves is malicious? The auctioneer holds all the commitments and could refuse to accept reveals from bidders they dislike, or claim commitments arrived after the deadline. The commit-reveal primitive does not help here. The standard mitigation is to publish commitments to a public log (a blockchain, a transparency log, a notary service) that the auctioneer does not control, so “I committed before the deadline” is something the bidder can prove.

  4. What if two bidders submit the same bid? Cryptographically fine — both commitments are valid, both reveals match. Tie-breaking is policy.

  5. Replay across auctions. A commitment published in auction 1 could be replayed into auction 2. Include the auction identifier inside the committed value (e.g., H("auction-N:bid=1750" || salt)) or accept that bidders need fresh salts per auction.

The library will not make these decisions for you. What it gives you is a primitive that, used correctly, makes the cryptographic verification a non-issue so you can focus on the protocol design.

A complete sketch

Putting it together:

from commit_reveal import CommitRevealScheme

scheme = CommitRevealScheme(hash_algorithm="sha3_256")

class Auction:
    def __init__(self, auction_id, reserve):
        self.auction_id = auction_id
        self.reserve = reserve
        self.commitments = {}      # bidder_id -> bytes
        self.reveals = {}          # bidder_id -> (bid, salt)

    def commit(self, bidder_id, bid):
        # bind the bid to this auction so it cannot be replayed
        payload = f"{self.auction_id}:{bid}"
        commitment, salt = scheme.commit(payload)
        self.commitments[bidder_id] = commitment
        return salt  # bidder keeps this

    def reveal(self, bidder_id, bid, salt):
        payload = f"{self.auction_id}:{bid}"
        if not scheme.reveal(payload, salt, self.commitments[bidder_id]):
            return False
        if bid < self.reserve:
            return False
        self.reveals[bidder_id] = bid
        return True

    def winner(self):
        if not self.reveals:
            return None
        return max(self.reveals.items(), key=lambda kv: kv[1])

This is a starting point, not a finished system. Real implementations need a deposit system to penalise non-revealers, a public commitment log to defend against a malicious auctioneer, and an explicit tie-breaking rule. Each of those is a protocol decision, not a cryptographic one.

What you get from using the library

Three things, in order of how often they will matter:

  1. A timing-safe reveal check. scheme.reveal() uses hmac.compare_digest to compare commitments. A naive == comparison can leak bit-level information about the commitment via timing differences; the library’s comparison does not.

  2. Salt handling that is hard to get wrong. Default 32-byte cryptographically random salts from secrets, validated at every entry point. You do not have to remember to generate one or worry about reuse.

  3. A refusal to accept insecure hashes. Try to construct CommitRevealScheme(hash_algorithm="md5") and you get SecurityError. This catches one of the most common ways early-stage protocols ship insecure commitments: someone defaults to md5 because it is fast.

These are not heroic features. They are floors. They are also exactly the kind of thing that hand-rolled commit-reveal implementations get wrong in production, because the protocol designer was thinking about the protocol, not the primitive. Use the library; spend your design energy on the protocol parts that actually need it.