Verifying the SPHINCS- Post-Quantum Signature Verifier
Specifying the SPHINCS- on-chain verifiers in Lean 4 and reducing their correctness to a small, named trust surface
On every public key, message, and signature, the Verity model performs the same SPHINCS- checks and reaches the same verdict: accept, reject, or revert.
The verifier accepts only if the +C grinding is present: the WOTS digit-sum hits its target and the forced FORS index is zero.
For the C13 keccak verifier this is machine-checked end to end: Lean's interpreter executes the transcribed assembly and the kernel checks that it computes the spec, resting on Lean's logic plus three named, documented residual assembly facts (section 5). The SHA-2 variant carries one explicit bridge assumption plus an opaque SHA-256 primitives constant.
This post is the result of a sidequest our friend Nico handed us. He spent the past few months working on SPHINCS-, a post-quantum signature scheme and wrote a family of on-chain verifier contracts for it. Our job was to take the Solidity source code, and for each verifier we modeled, reduce the claim “this contract matches the SPHINCS- algorithm” to the smallest assumptions possible. Using verity, we proved implementation-correctness against the algorithm (cryptographic unforgeability being a separate task). Here is how we did it.
What we verified
Two verifier contracts from lfglabs-dev/SPHINCS-, authored by Nicolas Consigny: SPHINCs-C13Asm.sol (keccak256, WOTS+C/FORS+C) and SLH-DSA-SHA2-128-24verifier.sol (SHA-256, FIPS-aligned). The repository also ships live C7 and C9 verifiers; those are not modeled or verified here, and the earlier C12 verifier (whose model a previous revision of this work covered) has been retired to legacy/ along with its model. Every claim below links to a specific line, pinned to commit e62d833 (current main).
1. What is SPHINCS-?
A digital signature lets you prove you authorized a message without revealing your secret. You hold a private key and publish a public key; only the key holder can produce a signature that the public key accepts. On Ethereum the on-chain code runs only the verify half. Today's wallets use ECDSA, whose security a large quantum computer would break, which is the motivation for post-quantum signatures.
SPHINCS+ (standardized by NIST as SLH-DSA, FIPS 205) rests on one assumption only: that a hash function is hard to invert and collision-resistant. It combines three pieces. WOTS+ is a one-time signature: a message digit says how far the signer already hashed a secret value, and the verifier hashes it the remaining steps, checking that it lands on the published public value (where is one hash step). XMSS is a Merkle tree of such keys; stacking trees into a hypertree reaches many signatures per key. FORS is a few-time signature that signs the message digest itself.
A full signature is a guided climb, from the message digest up to the one root the verifier already holds:
The “minus”: smaller signatures via grinding
Hash-based signatures are large, which is expensive on-chain. SPHINCS- shrinks them with a grinding trick, the +C in WOTS+C and FORS+C. Rather than transmit a checksum, the signer does a small proof-of-work: it searches for a randomizer so the WOTS digits hit a fixed target and one FORS index is forced to zero.
Those constants no longer travel in the signature. Verification depends on it: the verifier must re-check both equalities and refuse the signature on a mismatch. A contract has two ways to refuse: revert, which undoes the whole transaction, or return false, the ordinary “this signature did not check out” answer. The two are observably different to callers, so the spec keeps them distinct, and the exact revert-versus-reject boundary is one of the properties we prove about the model. (One known divergence between the model and the live source on this exact boundary is disclosed in the assumptions section.)
2. Two verifiers, one shape
We modeled two of the live verifier contracts. They share one entry point, verify(pkSeed, pkRoot, message, sig) → bool, and differ in hash, address encoding, and grinding rules.
Each contract is a single hand-written assembly block tuned for gas, exactly where a subtle masking or index bug hides.
C13 is also the first verifier in the family built on the FIPS 205 §11.2.2 uncompressed 32-byte address layout, keeping the keccak hash while aligning the ADRS encoding with the NIST reference. Concretely, every FORS hash is keyed by the position of this message in the hypertree: the digest's 22-bit index splits into a tree half and a leaf half (idxTree0 = htIdx >> 11, idxLeaf0 = htIdx & 0x7FF), and the per-level climb address folds the FORS tree number into the index word. The model and the spec are proved to agree at those real, per-message digits, not at a fixed placeholder.
The keccak verifier (C13) is fully proved against the executable model, up to three named residual assembly facts. The SHA-2 variant is modeled and specified to the same standard, but its model-to-spec step is a named assumption (its SHA-256 precompile call sits outside the pure fragment our interpreter covers), so treat its guarantees as the less settled of the two.
3. The specification, in three layers
You prove a contract correct against a specification: an independent description of what it should do. The risk is a spec so close to the code that both share one bug. We split it into three layers, each owning a different concern, and prove each layer refines the one above. Here “B refines A” just means everything B does is already allowed by A, so proving the lower layer can never contradict the upper one.
The top layer verifyBytes owns everything observable from outside the EVM and reduces a valid input to verifyParsed, the algorithm. Their combined contract is a single three-outcome function, where denotes a revert:
Why the outcome type is Option Bool, not Bool
A Solidity verifier has three observable outcomes: true, false, or revert. Folding revert into false would hide a real, observable difference.
So the spec returns Option Bool: none models a revert, and the two some values model the normal returns. See the three-way HyperResult outcome.
4. Six properties a correct verifier must enforce
For each property we give the rule, its spec definition, and the Solidity line that enforces it. A bug in any one is exploitable, or a denial of service against honest users. The spec is parametric over a Primitives package (hash, address encoding, signature parsing); for the keccak verifier that package is instantiated concretely, routed through the same pure Keccak function the interpreter evaluates, so nothing about the hashing is taken on faith in the implementation-correctness theorem. The spec link in each row is machine-checked; the .sol link is the Solidity line whose Lean transcription the proofs execute (section 5).
- 1
Signature shape
Exactly the right components at the right sizes: the randomizer, the FORS leaves and authentication paths, and one WOTS+ block plus Merkle path per hypertree layer.
- 2
Public-key canonicality
Both verifiers pack 16-byte hashes into 32-byte words and require the unused low bytes to be zero: pkSeed and pkRoot must each equal themselves masked by N_MASK, otherwise the contract reverts with “Invalid public key.” The C13 guard (added in review C13-V-f1, src/SPHINCs-C13Asm.sol) closes a previously silent dead branch: a non-top-aligned pkRoot can never equal the always-masked recomputed root, so every signature would quietly verify false, bricking the account. The guard fails loudly instead, and the C13 model transcribes it.
- 3
FORS forced-zero
For the +C variants the verifier recomputes the last FORS tree index; a non-zero value means the signature did not pay its proof-of-work and is refused.
- 4
WOTS+C grinding target
The recovered chain digits must sum to the agreed target (208 for C13); a wrong sum means a forged or malformed signature and is refused. The proofs run the model's digit-sum loop and show it fails exactly when the spec-side grinding predicate fails.
- 5
Hypertree index arithmetic
The low bits pick the leaf in the current tree; shifting them off yields the next tree’s index. Off-by-one here silently accepts signatures from the wrong tree.
- 6
Final acceptance
After the full climb the recovered root must equal the public root. The contract returns true exactly when they match, and only then.
5. From Solidity to a theorem
Rebuilding the assembly in Verity
Model.lean transcribes each contract's assembly block into Verity's EDSL statement by statement, keeping the same scratch-memory addresses, the same digest bit-extraction masks, the same loop bounds, the branchless Merkle swap, and the final mstore(0x00, valid); return(0x00, 0x20). Where the Solidity drops to raw Yul (revert(0, 0) and the Error(string) guards), the model uses typed unsafe-Yul fragments that carry an explicit proof obligation instead of an opaque escape hatch.
For the keccak verifier the transcription is not decoration: the proofs execute it. Verity ships a small interpreter for its statement language, and the theorems run c13VerifyBody through it, statement by statement, on a symbolic input. To keep that tractable the body is cut at natural seams, the digest computation, the index split, the FORS reconstruction, the hypertree climb, and each segment is pinned to the body by a definitional equation (a rfl that Lean checks by computing). Edit one opcode of the transcription and those equations stop type-checking: a transcription bug breaks the build.
Inside each segment, loop invariants carry the spec values through every iteration: the 19-level FORS climbs, the 43 WOTS chains, the two hypertree layers. And because the spec's hash package is instantiated with the same pure Keccak function the interpreter calls, “the model's keccak” and “the spec's keccak” are literally one definition; there is no axiom asserting they agree.
What is proved, and what is assumed
Everything from the raw bytes up to the algorithm is machine-checked, resting only on Lean's core logic. For C13 the step below that, the model computes exactly verifyBytes, is a theorem too, established by running the transcribed body through the interpreter. Its proof leans on three named residual assembly facts, each a small, local statement documented in place; the SHA-2 variant keeps its bridge as an explicit assumption. The exact axioms are listed in the trust-surface disclosure below.
| Theorem | Establishes | Status |
|---|---|---|
| verifyBytes_eq_verifySpec | byte boundary = algorithm (by case analysis) | Proven |
| byteVerifier_refines_spec | lifts it to any semantics | Proven |
| verifyBytes_isSome_iff | exact revert boundary | Proven |
| c13_refines_byte_spec | the interpreted model body computes verifyBytes | Proven (named framing facts) |
| c13_refines_spec | model refines the algorithm | Proven (same facts) |
| slhDsaSha2_128_24_refines_spec | SHA-2 model refines the algorithm | Proven modulo its named bridge assumption |
The full build contains no sorry, though that alone proves little: what matters is which axioms each theorem rests on, and Lean prints that list exactly. The three residual facts are statements of the same kind as hundreds of lemmas the build does check: that the WOTS chain loop's entry bindings hold at a named cutpoint state of the layer climb, and the same for its layer-1 and revert-branch twins. Each is recorded in place and none hides a new mathematical idea. Work-in-progress proofs discharging them live on the wip/axiom-discharge branch (PR #9); they have not yet compiled to completion (the monolithic attempt exceeded a 32 GB memory cap), so the three declarations remain axioms on main.
The exact trust surface (#print axioms)
c13_refines_spec depends on axioms: [propext, Classical.choice, Quot.sound, -- Lean logic c13_ok_..._lightweight_chain_inputs_layer0, -- WOTS loop entry facts c13_ok_..._lightweight_chain_cells_residual_layer1, -- layer-1 twin c13_reverted_..._lightweight_chain_cells_residual] -- revert-branch twin slhDsaSha2_128_24_refines_spec depends on axioms: [propext, Quot.sound, slhDsaSha2_128_24_Primitives, -- opaque SHA-2 semantics slhDsaSha2_128_24_refines_byte_spec] -- named bridge assumption
Note what is absent from the keccak list: no opaque primitives package and no bridge axiom. The hash semantics are a concrete Lean function shared by spec and interpreter, and the model-to-bytes step is a theorem. byteVerifier_refines_spec depends only on Lean's logic: the algorithmic core carries no domain assumptions at all.
Assumptions
The entire trust surface is small and named. These are the only things a reader must take on faith.
keccak / SHA-2 collision resistancestandard hash assumption, security onlyevery hash-based scheme
Cryptographic security reduces to the hash being hard to invert and collision-resistant; this is what SPHINCS+ itself is built on. It is not needed for the implementation-correctness theorems above: those say the contract computes the algorithm, whatever the hash's strength.
Residual assembly facts (C13: 3)accepted obligations, documented in placeverity/SphincsMinusVerifiers/Proofs.lean
Three named axioms in Proofs.lean:
c13_ok_beforeAuthOff_wotsPk_lightweight_chain_inputs_layer0,c13_ok_beforeAuthOff_wotsPk_lightweight_chain_cells_residual_layer1, andc13_reverted_layer0_beforeAuthOff_wotsPk_lightweight_chain_cells_residual. Each pins the WOTS chain loop's entry bindings at a named cutpoint state of the layer climb (layer 0, layer 1, and the revert branch). All higher-level facts that used to be assumed are theorems composed from these. Discharge is in progress on the wip/axiom-discharge branch (PR #9). The proof is per-variant: it covers C13's concrete parameters, not a generic parameterized family.Source-to-model transcription fidelitymanual review assumption, all variantsverity/SphincsMinusVerifiers/Model.lean
The models are hand-transcribed from the Solidity assembly and are never compiled into the contracts or replayed by Foundry, so correspondence rests on review. One divergence is known: on a failed grinding check (forced-zero index or WOTS digit sum) the model and spec revert, while the live contract, after a later review pass (C13-V-f2), returns
falsefor caller uniformity. Both behaviors refuse the signature; no input is accepted by one and not the other, but the revert-versus-reject boundary proved about the model does not match the deployed source on those two branches until the transcription is re-aligned.slhDsaSha2_128_24_refines_byte_spec (+ its Primitives)SHA-2 variant only, named bridge assumptionverity/SphincsMinusVerifiers/Proofs.lean
The SHA-2 verifier's model-to-bytes step and its opaque SHA-256/parse semantics remain explicit assumptions: its hashing goes through the SHA-256 precompile, which sits outside the pure fragment the interpreter covers. The keccak verifiers carry neither assumption.
Reproduce it
The proof is checked by Lean 4's kernel. If any step had a gap, the build would fail.
Verify it yourself
git clone https://github.com/lfglabs-dev/SPHINCS- cd SPHINCS-/verity git checkout e62d833a3ad70740ef6d3669b37aace6cc652f2a ./scripts/build.sh SphincsMinusVerifiers # caps Lean at 2 workers; ~16 GB RAM suffices
A successful build means every theorem type-checks against its stated axioms. See the proof inventory and blockers.
Learn more
New to this? What is a formal proof? explains the idea in two minutes. The reusable layered spec also required a small addition to Verity itself (typed unsafe-Yul fragments), landed upstream in verity#1942.