Verify a Selective-Disclosure Proof Yourself, Right Now
Our BBS+ implementation is unaudited: that is the most important fact on this page.
With that said: the verifier is public, unauthenticated, and you can exercise it end to end in about a minute. Here is exactly what we ran.
Do it yourself
$ mkdir /tmp/check && cd /tmp/check && npm init -y
$ npm i @solidus-network/[email protected]
Sign three messages, then produce a proof that discloses only the third:
import { BbsSecretKey, utf8 } from '@solidus-network/bbs'
const hex = (u) => Buffer.from(u).toString('hex')
const sk = await BbsSecretKey.generate()
const pk = await sk.publicKey()
const header = utf8('solidus-demo')
const msgs = ['name=Ada', 'birth_date=1815-12-10', 'country=UK'].map(utf8)
const sig = await sk.sign(header, msgs)
const proof = await sig.createProof({
pk, header, presentationHeader: utf8('ph'), messages: msgs, disclosedIndices: [2],
})
const r = await fetch('https://identity.solidus.network/v1/bbs/verify-proof', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({
proofHex: hex(proof.toBytes()), pubkeyHex: hex(pk.toBytes()),
headerHex: hex(header), phHex: hex(utf8('ph')),
disclosedMessages: [{ index: 2, message: hex(msgs[2]) }],
totalMessageCount: msgs.length,
}),
})
console.log(r.status, await r.text())
What we got, re-running every byte position on 2026-08-17:
local signature verifies → true
SERVER (valid proof) → 200 {"valid":true}
SERVER (byte 160, 200, 296 or 335 flipped) → 200 {"valid":false}
SERVER (byte 0, 5, 10, 40, 80 or 120 flipped) → 400 "proof could not be parsed:
invalid proof hex: invalid BBS+ proof"
Two outcomes, not one, and which you get depends on where you flip. A BBS+ proof begins with
compressed curve points. Corrupt one of those and the bytes are no longer a proof, so there is
nothing to verify and the answer is 400. Corrupt a scalar further in and the proof still parses,
gets verified, and fails: {"valid":false}.
⚠ Neither is a pass, and that is the whole point. But they are different statements. 400 says
we could not read this as a proof. {"valid":false} says we read it, checked it, and it is wrong.
An endpoint that answered {"valid":false} to unparseable bytes would be claiming a cryptographic
check it never performed.
No account. No API key. No header. The endpoint has no authentication step, and the route directly proves it by answering a stranger.
Why the tamper control is the part that matters
A verifier that returns true is worthless unless it refuses when it should. So the later runs
flip a single byte and get a refusal every time, in one of the two shapes above. Flip byte 200 to
see {"valid":false}; flip byte 0 to see the 400.
More controls, so a 400 cannot be mistaken for a working check:
POST /v1/bbs/verify-proof {} → 400 "proofHex: Required; pubkey…" ← route RAN
POST /v1/bbs/verify-proof {proofHex:"zz"…} → 400 "proofHex must be valid low…" ← validator works
POST /v1/bbs/zzz-not-a-route → 404 "Route POST:… not found" ← route exists
GET /v1/users/me/linked-accounts → 401 ← auth DOES gate elsewhere
The last one is the one people forget. Without it, "no auth required" could just mean "nothing on this host requires auth". It does, so this endpoint is public deliberately.
What this does NOT prove
1. It does not prove the implementation is safe. It is unaudited. A verifier that accepts valid proofs and rejects a byte-flipped one is doing the obvious thing correctly. Whether it resists an adversary who is constructing proofs to break it is exactly the question an audit answers, and nobody has asked it.
2. It does not prove unlinkability. The privacy property people care about (that two presentations of the same credential cannot be tied together) is not what this test exercises. It shows selective disclosure works mechanically. The unlinkability claim remains unaudited, and what the digest still reveals is the honest bound.
3. It does not prove the credential means anything. We generated the key ourselves. There is no issuer, no trust registry, no assurance level, nobody vouching for "country=UK". A verified proof from a key you made up tells you the mathematics ran, not that a fact is true.
4. It does not prove anybody uses this.
The gotcha our own package documents, which we would rather amplify than bury
Deriving a key from input material gives different keys in our two implementations. The Rust crate follows a later draft of the key-generation specification than the JavaScript library does, so the same input produces different secret keys in each. Signing, verification and proofs remain byte-compatible, only derivation diverges.
Their advice, in the package: transmit the key bytes directly rather than re-deriving.
We are pointing at this because they documented their own footgun, and a library that warns you about its own cross-language divergence is behaving better than one that lets you discover it in production.
One smaller note in the same spirit: two option names on the proof call had to be read from the type definitions rather than guessed, and our first two attempts failed because of it. The code on this page is the code that actually ran.

