Passes the issuer signs without seeing.
Strike passes through the envelope
Four parties, one page. The issuer holds an RSA key and signs whatever blinded value it is handed. The user blinds a random pass before sending it and unblinds the signature after. The verifier checks the signature with the issuer's public key and keeps a spent-list. Then the issuer tries to link — and can't.
1 Fire up the press issuer
Generates an RSA-PSS 2048-bit keypair with crypto.subtle.generateKey. The public key is what verifiers trust; the private exponent stays with the issuer.
2 Request a pass user → issuer
The issuer knows who is asking (that is how it rations passes — one per member, say). The user sends only a blinded value m′ = m·rᵉ mod n; the issuer returns s′ = m′ᵈ mod n; the user unblinds s = s′·r⁻¹ mod n.
If the requester field is a Solana address, the button reads its balance through this site's read-only RPC proxy — the kind of eligibility check an issuer might run at mint time. Pasting an address proves nothing about who controls it; a real issuer would also require a wallet signMessage proof. Never paste a private key or seed phrase here.
3 Your passes user wallet · this tab only
Each pass is a random 32-byte token plus an ordinary RSA-PSS signature on it. The coin face is drawn from the token's hash. A pass is a bearer instrument: whoever holds the code can redeem it.
4 The gate verifier
The verifier holds only the issuer's public key (imported from SPKI, verify-only) and a spent-list of nullifiers SHA-256("blindmint/nf/v1" ‖ token). Paste any pass code, or use the Redeem buttons above.
5 The issuer tries to link attack
The issuer has its full signing log (who asked, when, m′, s′) and — if it colludes with the verifier — every redeemed pass. For each (log entry i, redeemed pass j) it computes the only blinding factor that could join them, rᵢⱼ = s′ᵢ · sⱼ⁻¹ mod n, and checks rᵢⱼᵉ · mⱼ ≡ m′ᵢ (mod n).
Chaum's carbon-paper envelope, in modular arithmetic
David Chaum's 1982 picture: put a slip in an envelope lined with carbon paper; the signer presses through the envelope without opening it; you remove the envelope and the slip carries a valid signature. Here the envelope is a random factor r.
- Encode. The user picks a random 32-byte token and encodes it with EMSA-PSS (SHA-256, 32-byte salt, RFC 8017) into an integer
m < n. This is the RSABSSA approach of RFC 9474, so the final signature is a standard RSA-PSS signature. - Blind. Pick a random
rwithgcd(r, n) = 1, sendm′ = m · rᵉ mod n. Becauseris uniform,m′is a uniformly random number modn— it carries no information aboutm. - Sign blind. The issuer computes
s′ = m′ᵈ mod n(via CRT) and double-checkss′ᵉ ≡ m′before returning it. - Unblind.
s = s′ · r⁻¹ mod n. Sinces′ = mᵈ · r, this leavess = mᵈ— a signature on the token the issuer never saw. The page checkssᵉ ≡ mwith BigInt and asks the browser's nativecrypto.subtle.verify("RSA-PSS"); both must agree. - Redeem once. The verifier checks the signature, computes the nullifier
SHA-256("blindmint/nf/v1" ‖ token)and refuses it if already in the spent-list. The nullifier is bound to the token, not the signature, so re-signing the same token with a fresh PSS salt does not get a second redemption. - Why the issuer can't link. For any log entry and any redeemed pass,
r = s′·s⁻¹is a valid blinding factor that explains the pair exactly. Every pairing is equally consistent, so the log is useless for matching — step 5 computes this for every cell.
Who learns what
The issuer learns
- Who requested a pass, and when
- How many passes each requester got
- A blinded value
m′that is uniformly random modn - Nothing about the token or the final signature
The verifier learns
- That a valid pass from this issuer was presented
- The token and signature (bearer data), and when it was redeemed
- Whether that token was already spent
- Not which issuance it came from — even with the issuer's log
Nobody else learns
- The blinding factor
r(stays in the user's tab) - The mapping from requester to redeemed pass
- But network metadata — IP, timing, cookies — is not hidden by the maths
Solana angle: blind-signed passes fit gated access (a members' channel, an allowlist, a one-person-one-vote poll) where the issuer checks a wallet at mint time but the gate never sees that wallet. A production version could keep the nullifier set in an on-chain program so redemptions are publicly double-spend-checked. That program does not exist; this page keeps the spent-list in memory.
Known limitations — please read
- Reduces linkability, not anonymity. The maths unlinks issuance from redemption. It does not hide your IP address, browser fingerprint, or timing. Minting and redeeming one pass seconds apart, with nobody else minting, links you by timing alone.
- The anonymity set is everyone who minted with the same key. If only a few passes exist, matching is a guess among a few. An issuer could also tag users by giving each a different key — verifiers should accept only one published key and check that it never changes.
- Bearer passes can be stolen or sold. Whoever has the code can redeem it once. There is no recovery and no binding to a person.
- The issuer runs a raw RSA signing oracle. That key must be used for nothing else (RFC 9474 warns about this). The issuer cannot see or limit what it signs beyond counting requests.
- Spent-list is local. Double-spend protection here is one verifier's in-memory set, wiped on reload. Multiple verifiers need a shared, consistent nullifier store.
- Unaudited teaching code. BigInt arithmetic in JavaScript is not constant-time and has had no security review. Do not reuse this code or these keys to protect anything of value.
- No on-chain component and no token. There is no deployed program, and the ticker is a placeholder label.