eTendering Issue #1 · r00tk1t
Secure online tendering platform

Tendering

The eTendering hero, holding a stack of bid folders sealed with padlocks

I can't open your bid!

Public money is spent through tenders. They only work if nobody can peek at the bids or pick a favourite. For decades that meant sealed envelopes, a locked box and a public opening.

We rebuilt it with cryptography, so nobody, not even us running the platform, can read a bid, and anyone can check that nothing was changed. To see it work, play the game: it runs real cryptography on your phone, and nothing you type leaves it.

Chapter 0

The old way

A two-envelope tender: a technical proposal and a price, each sealed, dropped in a locked box before the deadline. Technical envelopes open first and get scored. Then the finalists' prices are read out and the lowest wins. It works, but it leaks in four places.

Four weak spots, and how we close them

Someone always holds the key to the box.No one holds the whole key. Chapter 2
Every finalist's price is read out loud.Only the winning price is ever opened. Chapter 6
Evaluators know whose bid they're scoring.Bids carry no name. Chapter 4
Weren't in the room? You trust the minutes.A public log anyone can check. Chapter 3

Same rules as paper. None of the leaks.

Behind the panel

We started from a study of a real manual process: a two-envelope sealed bid with a technical proposal and a price, opened in public, scored, then priced, with a standstill for appeals before the award is published.

Each weak spot above became a requirement. The platform must never hold a key that opens a bid. Losing prices must never be reconstructed anywhere. Evaluators must not learn who is behind a bid. And every step must leave evidence that anyone can check without trusting us.

The rest of this issue walks through how each one is met, in the order a tender runs.

Chapter 1

Sealed on your device

Your bid is encrypted in your own browser before it goes anywhere. The platform only ever stores locked data, and it never holds a key that opens a bid.

What leaves your browser

Your price: price.json
{"currency":"LKR","amount":"12450000.00"}
Sealed with AES-128-GCM, inside your browser
What the platform stores: ciphertext and tag
75c20640ac29f38d8da4aea5643b71caa22aa645a484d1cbf4c9eecb708bdc0330257f622d16ace6c7cc95098c40a8f9abe2f4584a82e9be33
The key that opens it. It never leaves as it is (chapter 2)
1a617fec7abaaec67856a499e7549644

Flip a single bit of the stored bytes and it no longer decrypts at all: the 16-byte tag at the end catches any change.

Seal your own price in the game

I store your bid. I just can't read it.

Behind the panel

Two envelopes, two keys

Every bid is two encrypted envelopes, just like paper. The technical one holds the proposal PDFs. The financial one holds price.json and an optional price breakdown. Each is sealed with AES-128-GCM under its own fresh 128-bit key, and the two keys must differ.

Where it happens

A Web Worker in the bidder's browser does all of it: it strips metadata from the PDFs, seals both envelopes, commits to the price and proves it is in range (chapter 6), splits the keys (chapter 2) and signs the bid. The key and the price leave the worker only as ciphertext, commitments, proofs and hashes.

Why "flip one bit" fails

GCM is authenticated encryption. The 16-byte tag at the end covers every byte, so a changed ciphertext doesn't decrypt to a slightly different price, it doesn't decrypt at all. Files are sealed in 1 MiB chunks, each with its own counter nonce, so chunks can't be reordered or dropped either.

This panel uses a random nonce and a single chunk to keep things short. Everything else is the same cipher your browser's WebCrypto provides.

Chapter 2

Nobody holds the key

The key from chapter 1 is cut into three shares, one for each of three independent custodians. One share on its own says nothing about the key. Two together rebuild it, and only after the deadline, when the rules allow.

One key, three shares

Custodian 10b1595ef81a1398b76864337d733ed8a
Custodian 23888abeb8c8d805c65f76bc5879a61d8
Custodian 329fc41e8779617116b278c6bb7fd1a16
  • Any one share alone: pure noise. Every possible key is still equally likely.
  • Any two shares together: the exact key, 1a617fec7abaaec67856a499e7549644, and the bid opens.

These are real shares of the key in chapter 1, made the same way as in our crypto package.

Split your own key in the game

One share is just noise. I'd need two custodians to agree.

Behind the panel

Shamir 2-of-3 for the technical envelope

The technical key is split with Shamir's secret sharing over the field GF(2^128). The key is the height of a straight line where it crosses zero, and custodian i gets the point at x = i. One point fits every possible line, so it gives away nothing. Any two points fix the line, and with it the key.

The demo above uses the same field, modulus and byte order as our crypto package, and matches its pycryptodome test vectors byte for byte.

Additive 3-of-3 for the price

The price, its commitment opening and the financial key are split differently: into three random numbers that add up to the secret, modulo the BLS12-381 group order. All three are needed. Those shares feed the private comparison in chapter 6.

Getting shares to custodians

Each share is sealed to one custodian with RSA-OAEP-3072 wrapping a fresh AES-128-GCM key. The platform relays the sealed shares but can't open them. A bid only counts once all three custodians have acknowledged their shares.

The trust assumption

Two opening custodians who collude can rebuild technical keys. That's the deal Shamir 2-of-3 makes: it survives one custodian being lost or dishonest. In a real deployment the three would be run by different organisations. Chapter 8 says where this demo falls short of that.

Chapter 3

A receipt for everything

Every action, from publishing a tender to awarding it, goes into an append-only public log, and you get a signed receipt for it. Change one character, and everyone can see.

Someone edits one entry

Entry 4, as logged
bid-event · slot 3 · sealed
Entry 4, edited
bid-event · slot 3 · sealeD
Root
03813a279aa067cf
Level 2
1a4891
eb5853
Level 1
a2227a
d2c067
340c6e
d5bcdc
Entries (leaves)
5714
8115
8430
a4cf
ab47
5f0d
4d38
2c56
Published root (cosigned)
cdbf683130eadcbc7a9850ea…
Root after the edit
03813a279aa067cfd0ea377a…

LEAF_MISMATCH: one changed letter changes every hash on its path, in red, up to the root. The witnesses already signed the old root, so the edit can't be hidden.

Try to forge your own receipt in the game

Change one byte and everyone can see it. Me included.

Behind the panel

An RFC 9162 Merkle log

Our witness log is a transparency log in the style of Certificate Transparency (RFC 9162). A leaf is hashed as SHA-256(0x00 ‖ entry) and a node as SHA-256(0x01 ‖ left ‖ right), so a leaf can never pass for a node. The panel above computes exactly that.

Receipts and inclusion proofs

When you act, you get a receipt signed by the log with Ed25519. Your browser then checks an inclusion proof: the few sibling hashes (shown in blue) that rebuild the root from your entry. Three hashes cover eight entries, and about twenty cover a million.

Witnesses, so the log can't lie to just you

The log publishes signed checkpoints: its size and root. Two independent cosigners countersign every checkpoint, and both must sign (2 of 2). A log that tried to show you one history and someone else another would need both witnesses to sign two different roots for the same size, which they refuse.

Trusted time

Each checkpoint carries an RFC 3161 timestamp from a timestamp authority. Deadlines are judged on that time, never the server's clock: a bid counts only if the timestamp, including its error margin, falls before the deadline.

The same check, on the real system

Save a real entry from the log, change one character of it, and run our verifier on it: it answers LEAF_MISMATCH, exactly like the panel above.

Chapter 4

Anonymous bidding

Bidders have no account and put no name on their bid. A group signature proves "I'm one of the companies invited to this tender" without saying which one. Each bidder still gets exactly one active bid.

One credential, two tenders

Tender A: Road resurfacing
tag c74ba6040bd8c1a4
Tender B: Hospital network
tag 8e867e86d48c7bc3
  • A first bid in tender A is accepted from "one of the invited companies". The platform doesn't learn which.
  • A second bid in tender A carries the same tag and is refused. Replace or withdraw, but never two.
  • A bid in tender B carries a completely different tag, so the two can't be linked.

The tags here only show the behaviour. The real tag is computed on the BLS12-381 curve (details below).

I know you were invited. I don't know who you are.

Behind the panel

BBS04 group signatures

The company running a tender invites bidders, and each one joins the tender's group once, on its own device, getting a credential the company can't sign with. Every bid action is then signed with a BBS04 group signature over BLS12-381 (hardened, with verifier-local revocation). The platform verifies it came from a member, but not which member.

The tag

Each signature carries a tag computed from the tender id and the bidder's secret. Inside one tender your tag is always the same, so you can replace or withdraw your bid but never hold two. In another tender your tag is completely different, so your bids can't be linked across tenders.

The panel above imitates that behaviour with a hash so it can run instantly. The real tag is nym = H(tenderId)^y on the BLS12-381 curve, and the signature is 592 bytes.

No accounts, no passwords

To act, a bidder answers a single-use challenge (128 bits, five minutes, bound to the tender and the action) with a group signature, and gets a short session token that works for that one tender only.

Lost a device?

The company can revoke a credential and move its bid slot to a new one. Every revocation list is signed and logged, and the verifier checks that no logged bid was made under a revoked credential.

Chapter 5

Blind scoring

Technical proposals open first, for evaluators only. Prices stay sealed until every evaluator has signed off the scores, so nobody can nudge a score once they know the prices.

The opening, in order

  1. DeadlineBids freeze on trusted time, witnessed in the log.

  2. AuthoriseThe officer and the whole committee sign.

  3. ReleaseCustodians send technical shares to each evaluator.

  4. ScoreEvaluators read the proposals. No prices.

  5. Sign offEvery evaluator signs the scores, or none count.

  6. Then pricesOnly now can the price comparison start.

The technical envelope opens at step 4, and only for the evaluators. The price envelope stays sealed through all six.

Score the work, not the price.

Behind the panel

Separate people, separate keys

Every role is a separate login and key, appointed per tender: the company, the opening officer, the evaluators, the award authority, the auditor, the custodians and the platform operator. The system refuses an appointment that would give one person two powers, such as opening and scoring.

Who signs what

A stage authorisation carries the officer's RSA-PSS-3072 signature plus a BLS aggregate signature from the entire opening committee: no threshold, no fallback, no replacement. Every signed object is canonical JSON (RFC 8785) with a domain tag, so a signature for one purpose can't be reused for another.

All evaluators, or no result

The final scores are an n-of-n BLS multisignature by every appointed evaluator. A bid qualifies when its total reaches the pass mark times the number of evaluators. Only qualified bids go into the price comparison.

Chapter 6

A private winner

On paper, every finalist hears every price. Here three custodians compare the sealed prices together and learn only which one is lowest. Losing prices are never put back together, anywhere.

Four sealed prices, one answer

BidCust. 1Cust. 2Cust. 3Result
A5da0c2ff2…1a8f9d69c…6faaee3d6…
B1b5efd349…6928dc864…635374eb7…Lowest
C611e2e562…3f6285b78…475a9a989…
D45336a457…39943df30…6913a66dd…

Each custodian holds one column: numbers that look random on their own, and are. Only all three in a row add up to that bid's price. The comparison runs on the shares and outputs one thing: B is lowest. A, C and D are never opened.

Find out if your bid wins in the game

The lowest price wins. No losing price is ever revealed.

Behind the panel

What the toy shows, and what it skips

The shares in the table are real: each price is split into three random numbers modulo the BLS12-381 group order r that add up to the price. Any one or two columns are uniformly random. To keep it instant, though, your phone then adds the shares back up to compare. The real system never does that.

The real comparison: MP-SPDZ, MASCOT

The three custodians run the comparison as a multi-party computation with MP-SPDZ (at least v0.4.3) using the MASCOT protocol over the prime field r. MASCOT has malicious security: it stays private and correct even if two of the three custodians cheat. A cheater can stop the run, but can't learn a price or change the result.

Bound to the bids

Each bidder commits to the price with a Pedersen commitment on BLS12-381 and proves the price is a 64-bit number. Before comparing, the computation checks every custodian's shares against those commitments, so no custodian can swap in a different price. If a check fails, the run aborts with a record that names the custodian.

What comes out

The output is the winning position and its commitment, signed by all three custodians with BLS. No price is in it. The company then opens only the winner's price envelope and identity, under a signed authorisation. There's a standstill for appeals, and the award is published.

Chapter 7

Anyone can check

From the first notice to the final award, every step can be verified without logging in: in the browser on our verify pages, or with a command-line verifier that trusts nothing from our server.

Verifying a whole tender

  1. Every tender version is signed by the company and its approver.
  2. Every signed object is in the public log, at the leaf its receipt names.
  3. Checkpoints are cosigned by both witnesses and timestamped.
  4. Every bid was accepted before the deadline, on trusted time.
  5. No bid was made under a revoked credential.
  6. The custodians acknowledged every counted bid.
  7. All evaluators signed the scores, and the opening was authorised.
  8. The winner came from the attested comparison, then the standstill, then publication.

Verified: 8 of 8 checks, exit code 0. Change any one signed object and the matching check fails by name.

Don't trust us. Check.

Behind the panel

Checked against the log, not against us

Our verify pages and the verifier CLI re-check signatures and log inclusion themselves. They take objects from the platform but trust none of them until each one matches its log entry. One command checks a tender's whole history, including that the log holds nothing the platform left out:

pnpm verifier tender verify --full-scan

There are matching commands for the closing set, revocations, slot transfers, the evaluation and the financial selection. Each exits 0 when everything verifies, or names the check that failed.

A blind platform, tested

"The platform can't read bids" is a claim we test, not just state. A forensic scan goes through everything an operator could get hold of (the database dumps, the stored files, the platform's key files, the mail server and the logs) and looks for any plaintext, price or link between a bidder and a company. It is built to find leaks, and it's checked against planted ones to prove it would.

Chapter 8

What we don't claim

Every security design rests on assumptions. These are ours, written down in our design documents along with every place we depart from the original design.

Collusion outside the system

Bidders agreeing prices in private is out of scope. No software can see a phone call.

Two custodians together

Two opening custodians who collude can open technical envelopes. That's why they must be run by independent organisations.

The issuing company

The company that invites bidders could link its members to their bids. We keep issuing and opening apart by procedure, not by maths.

Network metadata

The IP-hiding relay isn't built. We remove addresses from our logs instead.

A one-machine demo

Our demo runs the custodians, witnesses and mail server on one machine. In real life they would be separate machines run by separate people.

Still to come

Proxy warrants, crypto-erasure and benchmarks are next. The full tender, appeals included, runs today.

Behind the panel
  • The group signature scheme is CPA-anonymous: strong enough here, but weaker than full CCA anonymity.
  • If a winning bid turns out to be unopenable, its key is published with the rejection so anyone can check the decision. That makes that one bid's price readable.
  • Re-running a selection after an exclusion compares a subset of the same bids again, which reveals slightly more than a single run.
  • Stripping PDFs removes metadata, not what's visibly written on the page. A bidder who prints their company name on page one is not anonymous.
  • Evidence for invite-only tenders is either public or access-controlled, chosen per tender.

This site sends nothing anywhere. The game runs entirely on your phone.